构建一个会说会做的智能体
AI 智能体作业博客——会聊天、会干活的「AI 办公助理小办」
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15905 |
| 这个作业的目标 | 使用现代 AI 工具构建一个具备"语言交互(会说)"和"行动执行(会做)"能力的智能体,掌握智能体的基本原理与构建方法,为后续大项目打下基础 |
| 学号 | 3124004489(张吉安)、3124004468(侯剑锋) |
一、基本信息
- 小组成员:张吉安(3124004489)、侯剑锋(3124004468)
- GitHub 仓库:https://github.com/2553a/ai-agent
- 项目名称:AI 办公助理「小办」——能说会做的 Java 智能体
二、需求描述
2.1 场景定位
我们构建了一个 AI 办公助理智能体「小办」。它不只是一个"嘴上说说"的聊天机器人,而是一个真正能动手干活的助理:
- 能说(语言交互):用自然语言多轮对话,理解用户的任务意图,用友好的中文总结执行结果;
- 会做(行动执行):自动决策并调用工具,完成真实世界的操作——记录待办、查询真实天气、创建/读取文件、数学计算、时间查询。
2.2 功能需求
| 模块 | 需求描述 |
|---|---|
| 待办管理 | 用户说"帮我记一下明天交作业",智能体调用工具持久化存储任务;支持添加/查看/完成/删除,数据落盘不丢失 |
| 天气查询 | 用户问"广州天气怎么样",智能体调用外部天气 API(wttr.in)获取真实数据,并生成出行建议 |
| 文件操作 | 用户说"帮我创建文件 notes.txt,内容是…",智能体在沙箱工作区内真实创建文件,可读取、列出 |
| 数学计算 | 用户问"(3+5)*12 等于多少",智能体调用计算器工具精确求值,禁止"心算" |
| 时间查询 | 用户问"现在几点/星期几",返回当前日期时间与贴心提示(如距离周末天数) |
| 多轮记忆 | 记住上下文:"帮我记一下买酱油"→"查看我的待办"能正确关联;会话持久化,重启恢复 ⭐ |
| 语音交互 | 网页端支持语音输入(按住说话)与 AI 回复自动语音播报 ⭐ |
| 前端界面 | 网页聊天界面,可视化展示每一次工具调用的参数与结果 ⭐ |
| MCP 协议 | 作为标准 MCP Server 运行,可被 Claude Desktop 等客户端接入 ⭐ |
2.3 非功能需求
- 零门槛演示:没有 API Key、没有网络也要能演示完整的 Agent 循环(Mock 兜底模式);
- 最小依赖:教学项目,运行期第三方依赖只有 Gson,其余全部用 JDK 自带能力实现;
- 安全:文件操作限制在沙箱目录(防路径穿越)、数学求值手写词法分析(无脚本注入风险)、工具调用循环设最大轮数(防失控烧 token);
- 可测试:核心逻辑全部单元测试覆盖(45 个用例全绿)。
三、业务流描述
3.1 智能体总体工作流(ReAct 模式)
核心思想:智能体 = LLM(大脑)+ 工具(手脚)+ 循环(神经)。LLM 负责"理解与决策",工具负责"真实执行",Agent 主循环负责把两者串成"思考 → 行动 → 观察 → 再思考"的闭环(ReAct 模式)。
3.2 一次完整对话的时序(以"广州天气怎么样,适合出门吗"为例)
3.3 三种运行形态(同一核心,三种宿主)
java -jar ai-agent.jar → CLI 终端聊天(标准输入输出)
java -jar ai-agent.jar --web → 网页聊天(HTTP + 语音 + 工具轨迹可视化)
java -jar ai-agent.jar --mcp → MCP Server(stdio + JSON-RPC 2.0,接入 Claude Desktop)
四、实现说明
4.1 技术选型
| 选择 | 方案 | 理由 |
|---|---|---|
| 语言 | Java 17 + Maven | 作业要求;JDK 自带 HttpClient/HttpServer,天然适合最小依赖 |
| LLM 接入 | OpenAI 兼容 API(DeepSeek/智谱/通义) | 一套代码适配所有主流大模型,配置切换 |
| JSON | Gson | 单 jar 零传递依赖;Jackson 更适合 Spring 场景 |
| MCP | 手写协议实现(stdio + JSON-RPC 2.0) | 官方 Java SDK 依赖 Reactor 生态过重且 API 仍在变动;MCP 核心只有 initialize/tools/list/tools/call 三个方法,手写约 200 行反而让我们真正读懂了协议本质 |
| Web | JDK com.sun.net.httpserver + 原生前端 |
无需 Spring/Spring Boot,启动即用 |
| 语音 | Web Speech API(浏览器原生) | speechSynthesis 全浏览器可用;SpeechRecognition 做能力检测,不支持则隐藏按钮 |
4.2 项目结构
ai-agent/
├── pom.xml # 依赖:gson + junit5 + shade 打包
├── src/main/java/com/gdut/agent/
│ ├── Main.java # 入口:--web / --mcp / 默认 CLI
│ ├── config/AppConfig.java # 配置:环境变量 > ./agent.properties > 默认值
│ ├── core/
│ │ ├── Agent.java # ★ ReAct 主循环(Function Calling)
│ │ ├── Message.java # 消息模型(兼容 OpenAI 格式)
│ │ ├── ConversationMemory.java# ★ 多轮记忆:内存+JSON持久化+截断
│ │ └── ToolCall/ToolTrace/AgentReply
│ ├── llm/
│ │ ├── LlmClient.java # 接口抽象
│ │ ├── OpenAiClient.java # 真实大模型(JDK HttpClient)
│ │ └── MockLlmClient.java # ★ 离线规则引擎(无Key演示)
│ ├── tools/
│ │ ├── Tool.java / ToolParam.java # ★ 注解
│ │ ├── ToolRegistry.java # ★ 自动注册+Schema生成+反射分发
│ │ └── impl/ # Todo/Weather/File/Calculator/Time
│ ├── mcp/McpServer.java # ★ 手写 MCP 协议
│ └── web/WebServer.java # REST API + 静态页
├── src/main/resources/static/index.html # 聊天前端(含语音)
└── src/test/java/ # 45 个单元测试
4.3 关键实现
① Agent 主循环(智能体的"神经")
public AgentReply chat(String userInput) {
memory.addUser(userInput);
for (int round = 0; round < maxToolRounds; round++) { // 最大8轮防失控
LlmResponse resp = llm.chat(buildContext(), registry.toOpenAiToolsSchema());
if (!resp.hasToolCalls()) { // 模型给出最终回答
memory.addAssistantText(resp.content());
return new AgentReply(resp.content(), traces, mode);
}
memory.addAssistantToolCalls(resp.toolCalls()); // 记录决策
for (ToolCall call : resp.toolCalls()) { // 逐个执行工具
ToolResult r = registry.execute(call.name(), call.arguments());
memory.addToolResult(call.id(), r.text()); // 结果回传给下一轮
traces.add(new ToolTrace(...)); // 记录轨迹供前端展示
}
}
...
}
踩过的坑:OpenAI 协议里 tool_calls[].function.arguments 是JSON 字符串(不是对象),需要二次解析;tool_call_id 必须原样回传,否则 API 返回 400。
② 注解驱动的工具系统(加一个工具 = 写一个方法)
@Tool(name = "weather_query", description = "查询指定城市的实时天气…")
public String query(@ToolParam(name = "city", description = "城市名称") String city) { ... }
ToolRegistry 扫描 @Tool 注解,根据方法签名自动生成 OpenAI Function Calling 的 tools 数组和 MCP 的 inputSchema——一份工具代码,两种协议复用。新工具只需写一个方法加两个注解。
③ 手写 MCP Server(作业的核心技术要求)
MCP 基于 stdio 传输 JSON-RPC 2.0:每行一个消息,响应单行紧凑写 stdout、日志写 stderr。我们实现了握手与三个核心方法:
private JsonObject dispatch(String method, JsonObject req) {
return switch (method) {
case "initialize" -> initialize(req); // 回 protocolVersion/capabilities/serverInfo
case "ping" -> new JsonObject();
case "tools/list"-> toolsList(); // 复用 ToolRegistry 的 Schema
case "tools/call"-> toolsCall(req); // 业务失败用 result.isError=true 而非协议错误
default -> null; // JSON-RPC -32601
};
}
细节:notifications/initialized 是通知(无 id)不需要应答;工具执行失败按规范应放在 result.isError 而不是 JSON-RPC error——这两点我们都按 MCP 规范实现了,并通过 MCP Inspector 验证。
④ 无 Key 演示(MockLlmClient)
MockLlmClient 与 OpenAiClient 实现同一接口,用关键词 + 正则做意图识别,模拟 LLM 的两步行为:理解输入 → 发起 tool_calls;拿到结果 → 生成中文总结。这保证了课堂上没有网络和 Key 也能完整演示"能说会做",也让 Agent 主循环的调试不依赖外部服务。
⑤ 安全设计
- 文件工具:
resolve(path).normalize().startsWith(sandboxRoot)防路径穿越(有专门测试用例验证../evil.txt被拦截); - 计算器:手写词法分析 + 递归下降语法分析(JDK17 已移除 Nashorn,且 ScriptEngine 有注入风险);
- 对话循环:最多 8 轮工具调用,防止模型死循环烧 token。
4.4 测试
45 个单元测试全部通过,覆盖:
| 测试类 | 用例数 | 覆盖内容 |
|---|---|---|
| CalculatorToolTest | 6 | 优先级/括号/幂右结合/一元负号/全角符号/除零与非法输入 |
| TodoToolTest | 6 | 增查删改 + 持久化重启恢复 |
| FileToolTest | 5 | 读写往返/子目录/列目录/路径穿越拦截 |
| ToolRegistryTest | 7 | Schema 生成/参数分发/缺参/未知工具兜底 |
| ConversationMemoryTest | 5 | 截断边界对齐/持久化/工具链恢复/清空 |
| AgentMockTest | 8 | 端到端:意图→工具→总结全链路/多轮记忆关联/闲聊不调工具 |
| McpServerTest | 8 | MCP 握手/tools列表/调用/isError语义/通知/未知方法/非法输入 |
测试中发现并修复的真实 bug:
- "帮我创建文件 notes.txt,内容是:今天也要加油"被时间意图误拦截(正则
今天.*过宽)→ 改为精确匹配"今天几号/星期几"; 0.1+0.2输出0.30000000000000004→ 格式化时先消除浮点尾差;- "今天天气怎么样"把"今天"当成城市名 → 增加非城市词黑名单;
getDeclaredMethods()返回顺序不保证,测试按名字查找而非索引。
五、使用示例(功能演示)
5.1 CLI 模式(真实运行记录)
你: 帮我记一下:明天 9 点交软件工程作业
🔧 todo_add {"content":"明天 9 点交软件工程作业"} ✓ (3ms)
↳ {"success":true,"id":1,"content":"明天 9 点交软件工程作业","total":1}
小办: 好的,已经帮你记下「明天 9 点交软件工程作业」✅(任务 #1,当前共 1 条)
你: 查看我的待办
🔧 todo_list {} ✓ (1ms)
小办: 📋 你当前共有 1 条任务(未完成 1 条):
⬜ #1 明天 9 点交软件工程作业
你: (3+5)*12 等于多少
🔧 calculate {"expression":"(3+5)*12"} ✓ (1ms)
小办: 计算结果:(3+5)*12 = 96 ✅
你: 帮我创建文件 notes.txt,内容是:今天也要加油
🔧 file_write {"path":"notes.txt","content":"今天也要加油"} ✓ (1ms)
小办: 文件 `notes.txt` 创建成功,共写入 18 字节 ✅
你: 读取文件 notes.txt
🔧 file_read {"path":"notes.txt"} ✓ (0ms)
小办: 文件 `notes.txt` 的内容如下:
今天也要加油
```
你: 广州今天天气怎么样
🔧 weather_query {"city":"广州"} ✓ (1478ms) ← 真实调用外部API
小办: 「广州」当前天气:33°C,晴(体感 35°C,湿度 48%,风速 4 km/h;今日 27 ~ 36°C)
📣 出行建议:天气炎热,注意防晒补水 🌞
你: 现在几点了
🔧 now {} ✓ (0ms)
小办: 现在是 2026-10-20 11:19:45(星期二),距离周末还有 3 天。
你: 你好呀 ← 闲聊不调用工具
小办: 你好呀!我是你的 AI 办公助理「小办」😄 …
### 5.2 Web 模式
`java -jar ai-agent.jar --web` 后浏览器打开 http://localhost:8080:

特色功能:
- **工具调用轨迹卡片**:每次 LLM 决定调用工具,前端会出现灰色卡片,点击可展开查看**调用参数与返回结果**——智能体"会做"的直接证据;
- **语音输入**:点击 🎤 说话(Chrome/Edge),自动转文字发送;
- **语音播报**:开启 🔊 后 AI 回复自动朗读;
- **会话恢复**:刷新页面自动恢复历史对话(后端记忆持久化)。

### 5.3 MCP 模式(接入标准 MCP 客户端)
```bash
java -jar ai-agent.jar --mcp
# stdin 发送(Claude Desktop / MCP Inspector 会自动完成这些握手):
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05",...}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"calculate","arguments":{"expression":"1024/8"}}}
# stdout 响应(节选):
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"ai-office-agent","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"todo_add",...,"inputSchema":{...}},共10个工具]}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"success\":true,...\"result\":\"128\"}"}],"isError":false}}
配置进 Claude Desktop 后,可以直接对 Claude 说"帮我记一条待办",Claude 会通过 MCP 调用我们的工具真实执行。
六、GitHub 链接
**https://github.com/2553a/ai-agent
七、小组分工
| 成员 | 主要分工 |
|---|---|
| 侯剑锋(3124004468) | Agent 主循环(ReAct)与消息模型设计、@Tool 注解工具系统与 ToolRegistry、MCP Server 协议实现、CLI 交互模式、ConversationMemory 多轮记忆、单元测试设计与编写、博客技术章节 |
| 张吉安(3124004489) | OpenAiClient(OpenAI 兼容 Function Calling 联调)、MockLlmClient 意图引擎、WebServer 与前端聊天页(含语音/工具卡片)、WeatherTool 外部 API 对接、README 与博客使用示例整理 |
两人结对方式延续上次项目:一人编码时另一人实时复审,重大设计(MCP 手写 vs 官方 SDK、Mock 兜底策略)均共同决策。
八、成员心得
侯剑锋的心得
这次作业最大的收获是搞懂了"智能体"和"聊天机器人"的本质区别:聊天机器人只负责"生成下一句话",而智能体多了一个闭环——感知(理解输入)→ 决策(选择工具)→ 行动(真实执行)→ 观察(结果回传)→ 再决策。当我第一次在终端里看到 🔧 weather_query {"city":"广州"} ✓ (1478ms) 和真实的 33°C 数据时,那种"AI 真的动手干活了"的冲击感,比看一百篇教程都深刻。
技术上最有意思的决定是手写 MCP 协议而不是用官方 SDK。一开始我也想直接依赖 SDK 省事,但看了官方 Java SDK 的依赖树(Reactor、Spring 生态)后,我们决定照着 MCP 规范手写——结果只用约 200 行就实现了核心子集,而且被迫逐字读懂了 JSON-RPC 2.0 的通知与请求的区别、isError 与协议 error 的语义差异。用轮子之前先懂轮子,这是这次作业给我最好的工程教育。
调试过程中也踩了真实的坑:OpenAI 的 tool_calls.arguments 居然是"JSON 字符串"而不是对象,tool_call_id 不原样回传直接 400。这些协议细节书上不会讲,只有亲手撞过才记得住。
张吉安的心得
我负责的部分让我对"意图理解"有了全新的认识。做 MockLlmClient 时,我以为写几个 if-else 匹配关键词很简单,结果被真实语言教育了:"帮我创建文件 notes.txt,内容是:今天也要加油" 会先撞上"今天"触发时间意图;"今天天气怎么样" 里"今天"会被正则当成城市名。自然语言的歧义性远比想象中复杂——这让我真正理解了为什么需要大模型:它不是"更高级的关键词匹配",而是用概率分布去消解歧义。
做 Web 前端时,为了覆盖"语音输入输出"这个加分项,我调研后选择了浏览器原生 Web Speech API:speechSynthesis 全浏览器可用,SpeechRecognition 只有 Chrome/Edge 支持,所以我们做了能力检测降级——不支持的浏览器自动隐藏麦克风按钮。加分项不难,难的是优雅地降级。
另一个感触是 Mock 兜底设计的价值:有了 MockLlmClient,队友开发 Agent 循环时完全不依赖 API Key 和网络,演示时也不会被现场网络背刺。先让系统在无外部依赖的状态下跑通闭环,再接入真实服务——这个开发顺序我们会坚持到后面的大项目。
九、学习资源推荐
| 资源 | 说明 | 链接 |
|---|---|---|
| MCP 官方文档(中文) | 协议规范与各语言 SDK 入口,读懂 spec 再动手事半功倍 | https://modelcontextprotocol.io/zh-CN/docs |
| MCP 官方快速入门 | 作业指定的入门教程 | https://modelcontextprotocol.io/zh-CN/quickstart |
| OpenAI Function Calling 指南 | tool_calls / role=tool 消息格式的权威定义 | https://platform.openai.com/docs/guides/function-calling |
| Hello-Agents(动手学多智能体) | 作业推荐教程,从零实现智能体的完整课程 | https://github.com/datawhalechina/hello-agents |
| MCP 协议深度解析与实战应用 | 作业推荐实战教程 | 见作业页面 |
| 智谱 AI 开放平台 | glm-4-flash 模型免费,学生注册即用,适合联调 |
https://open.bigmodel.cn |
| DeepSeek 开放平台 | API 便宜、完全兼容 OpenAI 协议,本项目默认配置 | https://platform.deepseek.com |
| Anthropic: Building Effective Agents | Agent 设计模式(路由/编排/ReAct)的最佳实践 | https://www.anthropic.com/research/building-effective-agents |
| wttr.in | 本项目使用的免费天气 API,无需 Key | https://wttr.in |
| MDN Web Speech API | 语音识别与语音合成的浏览器原生能力 | https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Speech_API |
十、总结
这次作业让我们完整走通了智能体开发的三大核心问题:
- 怎么让 LLM"会做"——Function Calling:把工具的 JSON Schema 告诉模型,模型决定何时调用什么参数,程序负责真实执行并回传结果;
- 怎么让智能体"标准可接入"——MCP 协议:同一套工具通过标准协议暴露给任何 MCP 客户端(Claude Desktop/Cursor),工具一次编写、生态共享;
- 怎么让系统"始终可演示"——接口抽象 + Mock 兜底:LlmClient 接口隔离了真实 API 与本地规则引擎,核心循环不依赖任何外部服务。
智能体不是玄学,它就是 LLM + 工具 + 循环。当这三个零件在终端里第一次同时转动起来的那一刻,我们感觉自己摸到了下一代智能应用开发的门把手。这份经验和这套代码骨架,会成为我们大项目的起点。

浙公网安备 33010602011771号