今日工作内容

教学平台的 AI Agent 从「手动拼上下文」进化为「LLM 自主决策的 Tool Calling」模式,同时补齐了 Docker 容器化、单元测试、压测等工程化工作。


遇到的坑与解决方案

1. LangChain4j 1.13.0 OpenAiChatModel 的 HTTP 客户端 BUG

现象:调用 OpenAiChatModel.chat() 时抛出 Invalid HTTP method

原因:LangChain4j 1.13.0 的 OpenAiChatModel 内部使用 JdkHttpClient 发送非流式请求,但在 Java 21 + Spring Boot 4 环境下存在兼容性问题,HTTP 方法无法正常设置。

解决:弃用 OpenAiChatModel,直接用已有的 RestClient 手工实现 tool_calls 循环。核心改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 手工实现 tool_calls 循环,不依赖 OpenAiChatModel
for (int round = 0; round < MAX_TOOL_ROUNDS; round++) {
Map<String, Object> requestBody = buildRequest(messages, toolsList);
Map<String, Object> response = restClient.post()
.uri("/chat/completions")
.body(requestBody)
.retrieve()
.body(Map.class);

String finishReason = (String) choice.get("finish_reason");
if (!"tool_calls".equals(finishReason)) {
return (String) msg.get("content");
}
// 执行工具、回传结果、继续循环
}

启示:框架的 HTTP 客户端层不一定可靠,业务层直接使用 Spring RestClient 反而更可控。

2. Tool 参数需要 classId 但 LLM 不知道

现象:LLM 调用 queryStudentStats(studentName, classId) 时不知道 classId 该传多少,胡乱传值导致查不到数据。

解决:所有对外暴露的工具方法改为接受自然语言参数(学生姓名、班级名称),内部通过 classService.getClassByName()submissionMapper.selectList(Wrapper) 完成名称到 ID 的映射。

1
2
3
4
5
6
7
@Tool("查询学生的作业成绩统计,只需提供学生姓名")
public String queryStudentStats(@P("学生姓名") String studentName) {
// 直接用姓名查 submission 表,不限制班级
List<Submission> submissions = submissionMapper.selectList(
new LambdaQueryWrapper<Submission>()
.eq(Submission::getStudentName, studentName));
}

3. 新增 Controller 导致接口混乱

现象:先新建了 AgentChatController 放 Tool Calling 接口,导致 /api/agent/chat 和原有的 /api/chat/stream 功能重叠,前端不知道该调哪个。

解决:删掉 AgentChatController,直接在 ChatControllerstream 接口里注入 EduAITools,让前端零改动。

教训:功能迭代尽量在原有代码上扩充,新建 Controller 之前先确认是否真的需要。

4. 连续三次出现 Agent 对话

现象doChatWithTools 每轮 tool_calls 发一次 POST,造成 Gateway 侧看到多个对话。

解析:这是正常行为——第一次 POST 触发 LLM 返回 tool_calls,第二次 POST 回传工具结果得到最终回答。2-3 轮都合理。

5. JMeter 中文编码导致 XML 加载失败

现象:JMeter testname 属性中的中文字符经过 PowerShell Set-Content 处理后编码损坏,JMeter 报 XmlPullParserException

解决:所有 testname 改为纯英文,用 (Get-Content -Raw) -replace | Set-Content -Encoding UTF8 确保编码一致性。


技术决策与架构

Tool Calling 数据流

1
2
3
4
5
6
7
8
9
10
用户消息 → ChatController.streamMessage()

OpenClawServiceImpl.chatWithTools(message, history, eduAITools)

doChatWithTools(messages, toolInstances)
↓ 循环
第 1 次 POST → Gateway → LLM 判断 → 返回 tool_calls
第 2 次 POST → 回传工具结果 → LLM 最终回答

保存到 chat_history 表

当前接口一览

1
2
3
4
5
6
GET  /api/chat/health         健康检查
GET /api/chat/history 历史记录
POST /api/chat/clear 清空历史
GET /api/chat/stream 流式聊天(带 Tool Calling)
POST /api/chat/grade 作业批改(固定 workflow)
POST /api/onebot/rag QQ 端聊天(带 Tool Calling)

工具列表

工具 类型 数据源 输入
searchKnowledgeBase RAG document_chunk 表 + pgvector 关键词
queryStudentStats 数据库查询 submission 学生姓名
queryClassLearningStatus 数据库查询 submission + homework_evaluation + submission_errors 班级名称
queryHomeworkTasks 数据库查询 homework_task 班级名称
getCurrentTime 系统工具

后续需要做的事情

P0(面试必问)

  • Docker 多阶段构建:Dockerfile + docker-compose.yml 已写好,安装 Docker Desktop 后即可一键部署
  • 单元测试:已有 11 个测试覆盖工具层和 AI 编排层,剩余 searchKnowledgeBasegetCurrentTime 和 Controller 层待补

P1(建议补齐)

  • Metrics 监控:Actuator + Micrometer 自定义指标(AI 批改次数/耗时、Tool Calling 成功率)
  • Prompt 外置:把批改 Prompt 和工具描述从代码中移到 config/prompts/ 目录,支持热加载

P2(锦上添花)

  • CI/CD.github/workflows/maven.yml 自动构建 + 单元测试
  • LLM Token 追踪:每次 LLM 调用记录 Token 消耗,控制成本
  • JMeter 压测数据:已写好 load-test.jmx,跑完后将缓存加速比和限流准确率写入文档

压测数据

1
2
3
缓存对比:第一次 83ms → 第二次 8ms(Caffeine 缓存,加速 10 倍)
并发测试:5 线程同时请求,错误率 0%
限流测试:待跑,预期 20 并发下触发 429

备注

  • application.properties 已改为环境变量模式:${DB_HOST:localhost} 默认本机,容器化时通过环境变量覆盖
  • 如需启动 Docker:docker compose up -d(需先安装 Docker Desktop)
  • 启动前确保 OpenClaw Gateway(:18789)和 OneBot(:3000)在宿主机运行