OJ系统集成及借助大模型实现功能扩展(二)

(一)中完成了 OJ 后端与 remote_judge 的系统集成,并借助 MIMO 搭建了 agent-service 的基础架构,扩展了 5 个核心 AI 功能。这篇继续深入,重点展开 agent-service 的架构设计思路、关键模块的实现细节,以及用 MIMO 做 vibe-coding 过程中的迭代经验。


一、agent-service 架构再审视

(一)中给出了一张整体架构图,这里从"设计决策"的角度重新审视几个核心选择。

1.1 为什么是独立的微服务而不是 OJ 后端的模块

最开始的一个问题是:AI 功能直接写成 OJ 后端的一个 internal/ai/ 包不就行了,为什么单独拆一个 agent-service?

三个原因:

1. 生命周期解耦。 AI 推理的延迟和可靠性模型跟 CRUD 完全不同。OJ 后端处理的大多数请求在 10ms 内完成,而一次 LLM 调用可能需要 5-60 秒,还可能超时、返回垃圾、或者干脆不可用。放在同一个进程里,一个慢 AI 请求就会占住一个 goroutine,OOM 风险也更高。

2. 依赖隔离。 agent-service 依赖 langchaingo(60MB+ 的 NLP 库)做文档分割,依赖 Ollama SDK 做本地推理。如果放在 OJ 后端,这些依赖会污染整个项目的 go.mod。现在两个服务各管各的 go.mod,互不干扰。

3. 独立扩缩。 AI 请求有明显的突发特征——用户可能在某道题提交失败后一下子点好几次"诊断"。独立部署意味着可以单独对 agent-service 做限流、扩容,不影响判题链路。

1.2 为什么 agent-service 不直接暴露给前端

  错误做法: 前端 → agent-service (直接调用)
  正确做法: 前端 → OJ 后端 → agent-service (代理转发)

如果前端直连 agent-service:

  • agent-service 需要自己实现 JWT 鉴权、Rate Limit、CORS
  • agent-service 需要直接查 MySQL 获取题目信息、用户数据、提交历史
  • 安全面扩大:agent-service 多一个对外端口就多一个攻击面

OJ 后端代理后,agent-service 只需要做好一件事:接收组装好的上下文 JSON,调用 LLM,返回结构化结果。它不需要知道数据库结构、用户表长什么样、JWT 密钥是什么。这是一个典型的 BFF(Backend for Frontend)模式——OJ 后端是 BFF,agent-service 是下游领域服务。

1.3 模块职责划分

agent-service 的 internal/ 下有 5 个包,每个包只做一件事:

职责 设计模式
ai/ 封装 LLM 调用,提供统一 Chat()Embedding() 接口,内部分 provider 策略 Strategy Pattern
handler/ HTTP 请求解析、Prompt 构建、LLM 返回的结构化解析 Controller
rag/ 文档加载、文本分割、向量索引、相似度检索 Adapter(封装 langchaingo)
judge/ 调用 OJ 后端运行代码、轮询判题结果 Proxy
config/ 加载环境变量和 .env 文件 Singleton

这些包之间的依赖关系是单向的:handler 依赖 airagjudgerag 依赖 aiEmbedding() 方法;aijudge 独立无内部依赖。


二、AI Client 设计:双 Provider + 独立 Embedding

AI Client 是 agent-service 最核心的模块。在设计时面临两个问题:单点故障和 Embedding 耦合。

2.1 接口设计

// client.go — 统一 AI 接口
type Client struct {
    primary  *OpenAIClient   // MIMO API (OpenAI 兼容)
    fallback *OllamaClient   // Ollama 本地
    provider string          // "openai" | "ollama"
}

func (c *Client) Chat(messages []Message) (string, error)
func (c *Client) Embedding(text string) ([]float64, error)
func (c *Client) Health() error

设计要点:

  • primaryfallback 都是可选初始化的——如果 .env 没配 API Key,primary 为 nil;如果 Ollama 没装,fallback 为 nil
  • Chat()Embedding() 各自独立降级,不互相影响
  • provider 决定"谁优先","openai" 时先调 primary 再 fallback,"ollama" 时反过来

2.2 Chat 降级流程

flowchart TB Start(["Chat(messages)"]) --> Check{provider?} Check -->|"openai"| OAI["primary.Chat(messages)"] OAI --> OAIOK{err == nil?} OAIOK -->|"yes"| OAIResult["return result"] OAIOK -->|"no"| OAILog["log: primary failed"] OAILog --> OAIFB{"fallback != nil?"} OAIFB -->|"yes"| OAIFBCall["fallback.Chat(messages)"] OAIFB -->|"no"| Err["return ErrNoProvider"] OAIFBCall --> OAIFBOK{err == nil?} OAIFBOK -->|"yes"| OAIFBResult["return result"] OAIFBOK -->|"no"| Err Check -->|"ollama"| OLL["fallback.Chat(messages)"] OLL --> OLLOK{err == nil?} OLLOK -->|"yes"| OLLResult["return result"] OLLOK -->|"no"| OLLLog["log: ollama failed"] OLLLog --> OLLFB{"primary != nil?"} OLLFB -->|"yes"| OLLFBCall["primary.Chat(messages)"] OLLFB -->|"no"| Err OLLFBCall --> OLLFBOK{err == nil?} OLLFBOK -->|"yes"| OLLFBResult["return result"] OLLFBOK -->|"no"| Err style OAIResult fill:#c8e6c9,stroke:#4caf50 style OAIFBResult fill:#fff9c4,stroke:#fbc02d style OLLResult fill:#c8e6c9,stroke:#4caf50 style OLLFBResult fill:#fff9c4,stroke:#fbc02d style Err fill:#ffcdd2,stroke:#f44336

这个看似简单的流程,其实修复过一个 bug。最初的回退逻辑只有"先调 primary,失败调 fallback"一条路径,如果用户配了 AI_PROVIDER=ollama 想用本地模型优先,代码还是先调 MIMO。修复后的 switch-case 结构让两条路径完全对称。

2.3 Embedding 独立模型

这是开发中踩过的一个坑。最初的 OllamaClient 把对话和 Embedding 共用一个模型名:

// 错误做法
func (c *OllamaClient) Embedding(text string) ([]float64, error) {
    req := ollamaEmbedRequest{
        Model:  c.model,  // "qwen2.5-coder:7b" — 这是个对话模型,无法做 embedding!
    }
}

而 Ollama 上实际安装的 embedding 模型是 nomic-embed-text:latest。修复方案很简单但影响面不小:

  1. config.go 新增 EmbeddingModel 字段,默认 "nomic-embed-text:latest"
  2. OllamaClient 新增 embeddingModel 字段
  3. OpenAIClient 新增 embeddingModel 字段,默认 "text-embedding-3-small"
  4. NewClient()embeddingModel 分别传给两个子 client
  5. .env 新增 EMBEDDING_MODEL=nomic-embed-text:latest

修复后,对话和 Embedding 各自使用正确的模型,RAG 系统成功索引了 600 个文档块。


三、RAG 检索增强:从零到可用的迭代

RAG 是 agent-service 最复杂的一个模块。它经历了"未使用 → 种子数据 → 爬虫 → Embedding 修复 → 向量降级"五个阶段。

3.1 langchaingo 集成

agent-service 使用 langchaingo 作为文档处理库,具体用到两个能力:

  • documentloaders:递归加载目录下的 markdown 文件
  • textsplitter:按 chunkSize=1000、overlap=200 的规则分割文本
// rag/service.go — 文档加载与分割
func (s *Service) LoadFromDirectory(dir string) error {
    loader := documentloaders.NewRecursiveDirLoader(
        documentloaders.WithRoot(dir),
        documentloaders.WithMaxDepth(2),
        documentloaders.WithAllowExts("md"),
    )
    ctx := context.Background()
    docs, err := loader.LoadAndSplit(ctx, s.splitter)
    // ...
}

选 langchaingo 而不是自己写分割逻辑的原因是:它的 RecursiveCharacter 分割器会优先按段落、句子、词组的边界切分,避免把一句话从中间截断。对于 OI-Wiki 这种技术文档,断句的准确性直接影响检索质量。

3.2 向量检索与降级

func (s *Service) Search(ctx context.Context, query string, k int) ([]schema.Document, error) {
    // 尝试向量检索
    if s.embedder != nil {
        emb, err := s.embedder.CreateEmbedding(ctx, []string{query})
        if err == nil && len(emb) > 0 {
            return s.searchByVector(emb[0], k), nil
        }
        log.Printf("[rag] vector search unavailable: %v, falling back to keyword", err)
    }
    // 降级: 关键词匹配
    return s.searchByKeyword(query, k), nil
}

设计上宁可降级也不报错——AI 功能的核心价值是"有回复",即使没有向量检索,关键词匹配也能提供基本的相关性。

向量相似度使用余弦相似度:

func cosineSimilarity(a, b []float32) float32 {
    var dot, normA, normB float32
    for i := range a {
        dot += a[i] * b[i]
        normA += a[i] * a[i]
        normB += b[i] * b[i]
    }
    if normA == 0 || normB == 0 {
        return 0
    }
    return dot / (float32(math.Sqrt(float64(normA))) * float32(math.Sqrt(float64(normB))))
}

返回 top-3 最相关文档块,注入到 System Prompt 的 [相关知识] 部分。

3.3 爬虫工具

cmd/crawler/main.go 是一个独立的小工具,从 GitHub 的 OI-Wiki 仓库拉取 markdown 文档:

// 核心逻辑
func fetchPages(client *github.Client, ctx context.Context) {
    // 遍历 OI-Wiki 的 pages/ 目录
    // 对每个 .md 文件:
    //   1. 获取 raw content
    //   2. 写入 oiwiki_docs/
    //   3. 记录到 index.json
}

爬虫独立于 agent-service 主服务——每次 OI-Wiki 更新时手动跑一次即可,不需要嵌入到启动流程里。agent-service 启动时自动从 oiwiki_docs/ 加载文档。


四、Handler 设计:Prompt 构建与结构化解析

handler/handler.go 是所有 AI 端点的实现,约 800 行。每个端点的处理流程都是同一个模式:

① 解析请求体 → ② 构建 Prompt → ③ 调用 AI → ④ 解析 JSON 返回 → ⑤ 降级 rawMarkdown → ⑥ 写响应

4.1 Prompt 构建模式

以 CodeDiagnosis 为例:

func buildDiagnosisPrompt(req DiagnosisRequest, ragDocs []schema.Document, tagNames []string) []ai.Message {
    systemPrompt := fmt.Sprintf(`你是一个算法竞赛辅导专家。
    
## 你的任务
分析以下代码,找出导致 WA(答案错误)的根本原因。

## 算法标签
以下标签可用于描述算法:%s

## 相关知识
%s

## 输出格式(严格 JSON)
{
  "summary": "对代码问题的简短总结",
  "timeComplexity": "时间复杂度分析",
  "spaceComplexity": "空间复杂度分析", 
  "algorithmTags": ["从上述标签列表中选择"],
  "issues": [
    {
      "line": 行号(数字),
      "severity": "error|warning|info",
      "message": "问题描述",
      "hint": "修改建议"
    }
  ],
  "suggestions": ["改进建议"]
}`, strings.Join(tagNames, "、"), buildRAGContext(ragDocs))

    userPrompt := fmt.Sprintf(`## 题目信息
%s

## 用户代码
%s

## 判题结果
状态: %s
错误: %s

## 最近提交历史
%s`, req.ProblemContent, req.Code, req.JudgeStatus, req.ErrorMessage, buildHistory(req.RecentSubmissions))

    return []ai.Message{
        {Role: "system", Content: systemPrompt},
        {Role: "user", Content: userPrompt},
    }
}

Prompt 设计的几个要点:

  1. 标签字典注入 System Prompt:要求 AI 的 algorithmTags 必须从给定列表中选择,避免 AI 编造不存在的标签
  2. RAG 知识注入 System Prompt:OI-Wiki 检索结果放在 [相关知识] 部分,AI 回答时可以引用
  3. 强制 JSON 输出:在 System Prompt 中给出完整的 JSON Schema,要求严格遵循
  4. 提交历史作为上下文:User Prompt 中包含最近 3 次提交,让 AI 看到错误的演变

4.2 结构化解析与降级

AI 返回的内容不一定能解析为 JSON——可能模型返回了 markdown 包裹的 JSON、可能返回了纯文本、可能返回了不完整的 JSON。处理策略是尽力解析 + 优雅降级:

func parseAIResponse(raw string, target interface{}) (bool, string) {
    // 1. 尝试直接解析
    if err := json.Unmarshal([]byte(raw), target); err == nil {
        return true, raw
    }
    // 2. 尝试提取 ```json ... ``` 中的内容
    if extracted := extractJSONBlock(raw); extracted != "" {
        if err := json.Unmarshal([]byte(extracted), target); err == nil {
            return true, extracted
        }
    }
    // 3. 降级为 rawMarkdown
    return false, raw
}

前端收到 rawMarkdown 时会直接使用 AI 的原始回复渲染,虽然没有结构化展示那么好看,但至少不会空白。code 字段用负数(-1)标识解析失败,方便前端区分处理。


五、Solve Full 级别的判题验证闭环

Solve 功能有三个级别:hint(提示)、explain(解释)、full(完整代码)。full 级别是唯一一个会触发判题验证的——AI 生成的代码不一定正确,需要实际跑一遍。

5.1 状态机实现(OJ 后端侧)

状态机逻辑在 OJ 后端而非 agent-service 中实现,因为只有 OJ 后端能调 gRPC 判题:

func (h *AIHandler) solveFull(c *gin.Context, req SolveRequest) {
    maxRetries := 3
    for attempt := 0; attempt < maxRetries; attempt++ {
        // ① 发送题目信息给 agent-service
        agentResp, err := h.callAgent("/api/agent/solve", fullRequest(req, attempt))
        if err != nil {
            respondError(c, "AI 服务暂时不可用")
            return
        }

        code := agentResp.Code
        language := agentResp.Language

        // ③ 调用 remote_judge 判题
        judgeResult, err := h.runCode(c, req.ProblemID, code, language)
        if err != nil {
            respondError(c, "判题验证失败")
            return
        }

        // ④ 判题结果 == AC?
        if judgeResult.Status == "Accepted" {
            respondSuccess(c, code, agentResp.Explanation, agentResp.AlgorithmTags)
            return
        }

        // 反馈判题结果给 agent-service(下一轮 attempt)
        req.LastJudgeResult = judgeResult
    }
    // ⑥ 3 次都没 AC
    respondFail(c, "抱歉,我也无法通过此题")
}

5.2 判题验证的轮询优化

最初的实现是 time.Sleep(2 * time.Second) 等待判题结果——太粗暴。优化成了轮询:

func (h *AIHandler) runCode(c *gin.Context, problemID int, code, language string) (*JudgeResult, error) {
    runID, err := h.submitRun(problemID, code, language)
    if err != nil {
        return nil, err
    }

    for i := 0; i < 10; i++ {
        time.Sleep(500 * time.Millisecond)
        result, err := h.queryResult(runID)
        if err != nil {
            return nil, err
        }
        // 非中间态时提前退出
        if !isIntermediateStatus(result.Status) {
            return result, nil
        }
    }
    // 10 次后仍未结束,返回最新结果
    return h.queryResult(runID)
}

每 500ms 轮询一次,最多 10 次(5s),一旦进入终态立即返回。

5.3 错误反馈循环

重试时 agent-service 收到的请求会包含上一次的判题结果:

{
  "level": "full",
  "retryCount": 1,
  "lastJudgeResult": {
    "status": "Wrong Answer",
    "caseResults": [
      {"caseIndex": 0, "status": "Accepted"},
      {"caseIndex": 1, "status": "WrongAnswer", "input": "5\n1 2 3 4 5", "expected": "3", "actual": "5"}
    ]
  }
}

agent-service 会把 lastJudgeResult 放进 User Prompt:你的上一次代码在第 2 组测试点 WA,期望输出 3,实际输出 5。请修正后重新给出代码。——这相当于给了 AI 一个 debug 日志。


六、统一标签字典:跨服务的契约

标签字典是 OJ 后端和 agent-service 之间的一个隐式契约。OJ 后端维护 90+ 算法标签(分 8 个分类),agent-service 启动时拉取并缓存。

sequenceDiagram participant AS as agent-service participant OJ as OJ Backend participant DB as MySQL AS->>AS: 启动,初始化 TagCache AS->>OJ: GET /api/tags/names OJ->>DB: SELECT name FROM algorithm_tags DB-->>OJ: ["二分", "排序", "贪心", ...] OJ-->>AS: JSON ["二分", "排序", ...] AS->>AS: 缓存到内存 Note over AS: 每次构建 Prompt 时注入标签列表 rect rgb(232, 245, 233) Note over AS: AI 请求处理 AS->>AS: buildPrompt(tagNames, ...) Note over AS: "以下标签可用于描述算法:二分、排序、贪心、..." AS->>AS: parseAIResponse() AS->>AS: 校验 algorithmTags 中的每个标签都在 tagNames 中 end

为什么需要标签字典:

  1. 防止 AI 编造标签:没有约束时,AI 可能输出"快速幂优化 DP"这种不存在的标签名
  2. 前端下拉框一致性:题目创建页面的标签选择和 AI 输出的标签使用同一个来源
  3. 知识图谱对齐:知识图谱的节点 ID 必须来自标签字典,否则前端无法渲染

标签字典通过 /api/tags/api/tags/names 两个端点暴露。前者按分类分组(给前端下拉框),后者是纯名字列表(给 agent-service 注入 Prompt)。


七、Vibe-Coding 实战:MIMO 驱动开发的迭代模式

这部分不是讲代码,而是讲"怎么用 MIMO"——vibe-coding 的实际工作流和踩过的坑。

7.1 工作流

我的 vibe-coding 流程不是简单的"给需求→收代码→跑起来":

① 描述需求 (自然语言,尽量具体)
        ↓
② AI 反向提问,辅助理解需求
   (MIMO 会主动问我多个问题来澄清模糊点)
        ↓
③ AI 制定实施计划
   (列出要改的文件、改动思路、优先级)
        ↓
④ 我对计划进行纠错修改
   (AI 的计划常有遗漏,需要人工把关)
        ↓
⑤ AI 按计划执行优化,生成代码
        ↓
⑥ 我要求 AI 进行 Code Review
   (让 AI 自己审查自己刚写的代码)
        ↓
⑦ 实际测试 (go build / 功能验证)
   发现 Bug → 反馈给 AI 修正 → 回到 ⑥
        ↓
⑧ 总结改进,更新相关文档

关键点不是"AI 一次把代码写对",而是人机协作的迭代循环。第二步的 AI 反向提问尤其重要——很多时候问题本身描述得不清楚,AI 通过追问能主动帮我整理思路。第四步的计划纠错也必不可少,AI 对项目全局的理解有限,经常遗漏路由注册、数据库迁移这类需要全局视角的改动。

对话示例截图:

屏幕截图(502)

屏幕截图(503)

屏幕截图(504)

7.2 典型案例

案例 1:RAG 系统从无到有。 internal/rag/ 目录最初是 AI 生成的骨架代码,但 handler 中没有一处调用。我向 MIMO 描述需求后,AI 先追问了几个问题:"RAG 的数据来源是什么?是启动时加载还是实时拉取?如果 embedding 服务不可用,是否需要降级策略?"在我回答了这些之后,AI 给出了一个四步计划:种子数据 → 服务封装 → handler 注入 → 健康端点。我纠正了"handler 中要实现 rag-status 端点"这一点(AI 最初漏了),之后 AI 一次性完成了 RAG 的完整集成。AI 自己的 Code Review 又发现了 embedding 失败时缺少日志的问题并主动修复。

案例 2:Embedding 模型 bug。 这个问题不是代码逻辑错误,而是配置层面的疏漏。agent-service 的 .env 中没有 EMBEDDING_MODEL 配置,代码把对话模型 qwen2.5-coder:7b 直接用作 embedding,导致 Ollama 返回 404。修复需要改动 5 个文件——这种跨文件的配置类改动,AI 做起来又快又准,人工改反而容易漏。

案例 3:JSON 解析降级。 MIMO 生成的 parseAIResponse 最初只会直接 json.Unmarshal,但 AI 有时候返回的 JSON 外面包裹了 markdown 代码块。我让 AI 做了 Code Review 后,AI 自己发现了这个问题,加了一层 extractJSONBlock 正则提取,成功率从约 70% 提升到接近 100%。

7.3 效率数据

用 MIMO 做 vibe-coding 一周多时间的实际产出:

类别 数量 说明
新增文件 8 个 seed_data.go, service.go, store.go, crawler/main.go, seed_problems.go, RatingHistoryChart.vue, template.go
修改文件 40+ 个 覆盖后端 20+ 文件、前端 15+ 文件、agent-service 10+ 文件
修复缺陷 34 项 P0-P3 + 数据库 + 数据源,100% 完成
新增题目 47 道 覆盖 10 个分类
新增 API 10+ 个 AI 端点 + 标签字典 + Rating 历史 + 运行验证

单靠手写这些代码,按我的经验至少需要 3-4 周。MIMO 把周期压缩到了 1 周多,核心提速点在于:重复性工作(如 47 道题目的数据录入、10 处错误处理的批量添加)几乎是零成本完成的。


八、总结与后续方向

这篇深入展开了 agent-service 的四个核心模块设计:

AI Client:双 Provider 降级策略 + 独立 Embedding 模型。Chat()Embedding() 各自独立降级,对话失败不影响向量检索,反之亦然。

RAG 系统:经历了"未使用 → 种子数据 → 爬虫 → Embedding 修复 → 向量降级"五个阶段的迭代。核心设计是"向量优先 + 关键词降级",宁可降低精度也不报错。

Handler:统一的 Prompt 构建 + 结构化解析 + 降级 rawMarkdown 模式。每个端点的 Prompt 都注入标签字典和 RAG 检索结果。

Solve 状态机:AI 生成代码 → 判题验证 → 反馈错误 → AI 修正 → 重新判题,最多 3 轮。判题轮询 500ms/次,终态提前返回。

后续值得继续深入的方向:

  • SSE 流式输出:agent-service 新增 /api/agent/chat/stream 端点,Ollama Stream: true,前端 EventSource 接收。这是用户体验的质变——当前用户需要等待完整响应(5-60s),流式输出可以让用户看到逐 token 生成过程。
  • 思路一的工程评估:薄代理 + 共享数据库方案对于大输入(如 100KB 的测试点文件)有显著的网络传输优势。需要评估数据库耦合的代价是否值得。
  • Prompt 模板化管理:当前 Prompt 字符串硬编码在 handler 中,后续可以抽成模板文件,方便非开发人员(如算法教练)调优。

博客中涉及的完整代码见 fused 融合仓库:agent-service/

posted @ 2026-06-13 02:09  宋佳奇  阅读(2)  评论(0)    收藏  举报