OJ系统集成及借助大模型实现功能扩展(三)
前两篇完成了判题系统集成和 agent-service 的基础架构搭建。这套 AI 辅助系统跑起来之后,很快暴露了三个层面的问题:知识图谱的可视化展示过于扁平,agent-service 的固定编排流程过于僵硬,以及 RAG 检索质量不稳定。本篇记录这三个问题的解决过程。
一、知识图谱:从数据库驱动到硬编码驱动
1.1 原有架构的问题
(一)中设计了基于 OI-Wiki 的知识图谱——73 个算法节点、层级布局、按掌握度着色。实现方式是把知识点存在 MySQL 的 knowledge_points 表和 knowledge_edges 表中,前端请求 /api/knowledge/graph 时后端查表构造 JSON 返回。
这个设计跑了不到一周就暴露了两个问题:
维护成本高。 每次想加一个知识点,要改 seed_knowledge.go 里的 Go 结构体,启动时写入 MySQL。如果有重复数据还要手动判断跳过。一个 200 行的知识点定义,被拆成了 seed 函数、GORM 模型、handler 查询三段代码,改一次要跨三个文件。
数据库毫无必要。 知识点树是纯静态数据——不会因为用户的做题记录而改变,不存在"写入-读取"的周期。把它放在 MySQL 里纯粹是增加了查询延迟和运维复杂度。而且为了做题目和知识点的关联,还多了一张 problem_knowledge_points 桥接表——但实际上题目已经有 tags JSON 字段了。

1.2 解决方案:硬编码数据层
具体做法:
-
新建
internal/data/knowledge.go:把原来seed_knowledge.go里的知识点定义直接搬过来,改成KnowledgeTree()函数返回[]KPNode切片。200+ 个知识点、11 个分类、完整的父子关系和颜色图标,全部在一个文件里。 -
KnowledgeHandler 不再查 DB:
Graph()方法从data.KnowledgeTree()拿数据,用数组下标作为稳定 ID(替代 MySQL 的自增 ID),在内存里构建节点树和边关系直接返回。 -
删除三张表:
knowledge_points、knowledge_edges、algorithm_tags连同对应的 GORM 模型、seed 函数、AutoMigrate 注册全部删除。标签字典由data.Tags()函数直接从KnowledgeTree()推导。 -
题目关联改用 JSON_CONTAINS:原来的
problem_knowledge_points桥接表用JSON_CONTAINS(problems.tags, '"哈希表"')替代,题目标签和知识点名称天然对应。
1.3 可视化改进
前后端数据格式统一后,前端发现一个问题:知识图谱节点 ID 从 0 开始,而 ECharts 的树形布局中 parentId || 0 会导致 ID=0 的根节点把自己当作自己的子节点,触发无限递归。
修复方式是将节点 ID 从 i 改为 i+1,保留 ID=0 作为虚拟根节点:
// knowledge.go - Graph handler
for i, p := range points {
nameToID[p.Name] = i + 1 // ID 从 1 开始
nodes[i] = Node{
ID: i + 1,
Name: p.Name,
...
}
}
同时优化了前端 Markdown 解析,AI 返回的分析结果如果是 \``json ... ```` 格式也能正确提取。

AI 分析薄弱点功能将分析结果以颜色覆盖层的方式叠加在静态知识图谱上,绿色节点表示掌握较好的知识点,红色节点表示薄弱环节:

二、agent-service 架构改造:从编排流程到 Tool Calling
2.1 原有架构的局限
(二)中描述的 agent-service 是典型的"编排式"架构:
每一步都是 Go 代码在控制:什么时候调 LLM、什么时候调数据库、什么时候把结果回传给 LLM。这导致两个问题:
扩展新功能要改代码。 比如想加一个"AI 根据薄弱点推荐题目"的功能,需要在 handler 里写新的编排逻辑:查薄弱标签 → 查未做题 → 调 LLM 编排 → 创建题单。每一步都是一段 Go 代码。
LLM 无法自主决策。 很多场景下 LLM 比 Go 代码更清楚需要什么数据。比如用户说"帮我看看我动态规划学得怎么样",AI 应该自己决定查哪些题、分析哪些指标。但在编排模式下,Go 代码已经替它决定了"查哪些题、查什么粒度"。
2.2 Tool Calling 架构设计
改造的核心思想是:让 LLM 自主决定调用什么工具、以什么参数调用、调用几次。Go 代码只负责提供工具和执行业务逻辑。
2.3 实现要点
1. OpenAIClient 扩展 Tool Calling 支持
agent-service/internal/ai/openai.go 的 openaiChatRequest 新增三个字段:
type openaiChatRequest struct {
Model string `json:"model"`
Messages []openaiMessage `json:"messages"`
Tools []toolDefinition `json:"tools,omitempty"` // 新增
ToolChoice interface{} `json:"tool_choice,omitempty"` // 新增
Thinking *thinkingOption `json:"thinking,omitempty"` // 新增(关闭思考)
}
响应解析增加 tool_calls 提取:
type openaiMessage struct {
Role string `json:"role"`
Content *string `json:"content"` // 指针区分空和缺失
ToolCalls json.RawMessage `json:"tool_calls,omitempty"` // 新增
ToolCallID string `json:"tool_call_id,omitempty"`
}
ChatWithTools 方法在发送请求时附带工具定义,解析响应时提取 tool_calls 数组,每个 tool_call 包含 id、name、arguments(JSON 字符串)。这些信息随后传给 Tool Executor 去执行。
2. 两阶段 Agent 循环
不是简单的一轮 LLM 调用。整个对话分为两个阶段:
Phase 1: 工具收集循环
LLM 调工具 → 执行 → 结果回传 → LLM 再调 → ... → LLM 觉得够了 → 退出
Phase 2: 最终合成(不传工具)
结合收集到的所有数据 → LLM 生成最终回复
Phase 2 的关键细节:把 Phase 1 中的 role: "tool" 消息转换为 role: "user" 消息再传给 LLM,因为纯 Chat 接口不认识 tool 角色。同时插入一条过渡系统消息:"以上是内部工具调用和数据收集过程。现在直接向用户给出最终答案。不要点评之前的数据获取过程是否相关、是否杂乱。" 这让 LLM 在 Phase 2 给出干净的最终回答,不会出现"看来之前 RAG 返回的数据不太相关"这类元评论。
3. 工具定义和模式映射
每个 mode 有自己可用的工具集和最大调用轮数:
| mode | 工具 | 最大轮数 | 典型行为 |
|---|---|---|---|
chat |
search_problems, query_user_problems, retrieve_knowledge, get_user_code | 3 | 自由对话,AI 自主决定是否调工具 |
code-diagnosis |
无 | 0 | 纯代码分析,跳过 Phase 1 |
generate-solution |
无 | 0 | 基于已有代码生成题解 |
knowledge-graph |
search_problems, query_user_problems | 1 | AI 查做题记录 → 分析薄弱点 |
study-plan |
search_problems, query_user_problems | 2 | 查薄弱标签 → 查未做题 → 编排题单 |
solve |
全部工具 | 3 | 查题、查代码、提交判题、修正 |
2.4 OJ 后端扩展:Agent 内部 API
为了让 agent-service 能通过 HTTP 执行工具调用,OJ 后端新增 /api/agent/ 路由组,不走 JWT 认证,通过 X-User-ID 头识别用户:
| 端点 | 用途 | 被哪个工具调用 |
|---|---|---|
POST /api/agent/problems |
查询用户做题记录 | query_user_problems |
POST /api/agent/judge |
提交代码评测 | submit_code |
POST /api/agent/code |
获取最近提交的代码 | get_user_code |
POST /api/agent/search-problems |
模糊搜索题目 | search_problems |
这些端点在 router.go 中单独建一个 agentGroup,使用 agentAuth 中间件(仅检查 X-User-ID),与需要 JWT 的 authed 组隔离开:
agentGroup := r.Group("/api", agentAuth)
{
agentGroup.POST("/agent/problems", ai.QueryUserProblems)
agentGroup.POST("/agent/judge", ai.SubmitAndJudge)
agentGroup.POST("/agent/code", ai.GetUserCode)
agentGroup.POST("/agent/search-problems", ai.SearchProblems)
}
2.5 模型选择:DeepSeek V4 Flash
整个 Tool Calling 架构的基础是 LLM 必须支持 function calling。最初的 deepseek-chat 模型(路由到 deepseek-v3)完全不认 tools 字段——发过去的工具定义被当成纯文本忽略,LLM 返回的是"我先查一下你的记录..."这种文字承诺而非实际的 tool_calls。
切换到 deepseek-v4-flash 后问题立刻消失。curl 测试验证:
curl -s https://api.deepseek.com/v1/chat/completions \
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"What is the weather in Beijing?"}],
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{...}}}],
"tool_choice":"auto"}'
# → tool_calls: [{"function": {"name": "get_weather", "arguments": "{\"city\": \"Beijing\"}"}}]
2.6 前端适配:对话关联题目
AIChat 组件新增了题目关联功能——输入框左侧的 + 按钮可以关联最多 3 道题目。关联后题目信息(ID + title + tags)拼入 system prompt,LLM 可以看到这些题目的基本信息,需要更多细节时可以通过工具查询。
前端发送的 problem_ids 数组由 OJ 后端快速路径转换为 ExtraProblems []ProblemContext,透传给 agent-service 的 UnifiedChatRequest,最终在 buildModeMessages 中拼入系统提示词。
三、RAG 检索改进
3.1 原始方案的问题
(二)中 RAG 使用 RecursiveCharacter 分割器,chunk 1000 字符。两个问题:
相关性不足。 OI-Wiki 文档的向量相似度检索经常返回不相关内容。搜"动态规划",余弦相似度最高的可能是"堆排序"和"平衡树旋转"——因为向量空间里它们的距离意外地近。
分块无结构。 1000 字符的随机切割经常把 ## 定义 这一节从中间断开,LLM 拿到的 chunk 缺少上下文,不知道这段内容属于哪个文档、哪个章节。
3.2 三步改进
第一步:Tag Boost。 利用 OI-Wiki 文档的 YAML front matter,把 title 和 category 字段作为 tags 存入每个 chunk 的 metadata。检索时如果 query 中的词命中文档的 tag,该 chunk 的相似度分数 ×3。
// LoadFromDirectory 中的标签提取
tags := []string{}
if t, ok := docs[i].Metadata["title"].(string); ok && t != "" {
tags = append(tags, t)
}
if c, ok := docs[i].Metadata["category"].(string); ok && c != "" {
tags = append(tags, c)
}
docs[i].Metadata["tags"] = tags
在搜索评分阶段:
if tagMatchBoost(boostTags, doc.Doc.Metadata) {
score *= 3.0 // 标签命中,权重翻三倍
}
效果立竿见影:搜"动态规划",返回的从"堆排序、平衡树、回溯、并查集"变成"记忆化搜索、数位DP、状压DP、区间DP、背包DP"——全部是 category: "动态规划" 的文档。
第二步:按节分块。 弃用 RecursiveCharacter(按字符数随机切割),改为自定义的按 ## 标题分块逻辑:
// splitByHeadings — 按 ## 标题边界切分,保持每节的完整性
func splitByHeadings(docs []schema.Document, maxLen int) []schema.Document {
for _, doc := range docs {
sections := splitByMarker(doc.PageContent, "\n## ", maxLen)
// 每节成为一个独立 chunk
}
}
分块策略:
- 先按
##标题切分,每节独立成块 - 大节(超过 maxLen)按段落边界(
\n\n)进一步切分 - 超大段落才硬截断
这样 ## 定义 + 完整的定义内容永远在一个 chunk 里,不会出现标题和正文分离的尴尬。
第三步:上下文前缀。 每个 chunk 的文本内容前追加 [文档名] [分类] 前缀:
[记忆化搜索] [动态规划]
---
title: "记忆化搜索"
category: "动态规划"
...
## 定义
记忆化搜索是一种通过记录已经遍历过的状态的信息...
LLM 拿到碎片也能立即知道这个 chunk 属于哪个知识体系。加上 tag boost 的三倍权重,相关性和可读性都得到了保证。
3.3 提示词优化
早期版本的提示词试图用规则约束 LLM 行为——"普通闲聊不调工具""用户没指定题号时先问用户"——结果 LLM 变得非常死板:用户说"讲讲动态规划",AI 收到"知识讲解"被归为"普通闲聊",不调任何工具,给出的回答不结合用户做题背景。
最终版提示词只有一句话:
你是竞赛AI助手,用中文回答时结合用户实际情况做个性化讲解。
工具:search_problems搜题目、query_user_problems查做题记录、retrieve_knowledge查知识、get_user_code取代码。
把"什么时候该用什么工具"的决策权还给 LLM——不是通过提示词规定,而是通过工具描述教会 LLM:
query_user_problems: 查询当前用户的做题记录和知识点统计。
当用户让你讲解知识点、分析薄弱点、推荐题目时,先调用此工具了解他的掌握情况。
示例:query_user_problems({tags:["动态规划"]}) → 返回用户做过的DP题列表和DP标签的AC率。
有了数据后再结合用户实际掌握情况做讲解,而不是泛泛而谈。
每个工具描述都包含:
- 触发时机:什么时候该用
- 调用示例:参数格式
- 预期返回:拿到数据后怎么用
LLM 从工具描述中自学习使用策略,提示词保持极简。

四、总结
| 模块 | 改造前 | 改造后 |
|---|---|---|
| 知识图谱 | MySQL 存储,每次请求查表 | 硬编码 internal/data/knowledge.go,内存读取 |
| AI 架构 | 编排式,Go 代码控制每一步 | Tool Calling Agent,LLM 自主决策 |
| RAG 分块 | RecursiveCharacter 1000 字符随机切 | 按 ## 标题分块 + Tag Boost ×3 + 上下文前缀 |
| AI 提示词 | 满篇规则约束 | 极简系统提示词 + 丰富工具描述 |
| 模型 | deepseek-chat (不支持 tools) | deepseek-v4-flash (完整 function calling) |
核心收获:给 LLM 自主权比预设规则更有效。编排流程看似精确控制,实则把 LLM 当成了"按要求填模板的机器"——丧失了它判断上下文、自主决策的优势。Tool Calling 架构把"做什么"的决策交给 LLM,Go 代码只负责"怎么做"的执行,这才是 AI Agent 该有的分工。

浙公网安备 33010602011771号