构建一个会说会做的智能体

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 非功能需求

  1. 零门槛演示:没有 API Key、没有网络也要能演示完整的 Agent 循环(Mock 兜底模式);
  2. 最小依赖:教学项目,运行期第三方依赖只有 Gson,其余全部用 JDK 自带能力实现;
  3. 安全:文件操作限制在沙箱目录(防路径穿越)、数学求值手写词法分析(无脚本注入风险)、工具调用循环设最大轮数(防失控烧 token);
  4. 可测试:核心逻辑全部单元测试覆盖(45 个用例全绿)。

三、业务流描述

3.1 智能体总体工作流(ReAct 模式)

flowchart TD A[用户自然语言输入] --> B[Agent 主循环] B --> C[组装上下文<br/>系统提示 + 多轮记忆 + 工具Schema] C --> D[调用大模型 LLM] D --> E{LLM 的决策?} E -- 返回 tool_calls --> F[ToolRegistry 反射执行工具<br/>待办/天气/文件/计算/时间] F --> G[工具结果作为 role=tool 消息回传] G --> D E -- 返回最终文本 --> H[自然语言总结回复] H --> I[(写入记忆持久化)] I --> J[回复用户<br/>CLI气泡 / 网页+语音播报]

核心思想:智能体 = LLM(大脑)+ 工具(手脚)+ 循环(神经)。LLM 负责"理解与决策",工具负责"真实执行",Agent 主循环负责把两者串成"思考 → 行动 → 观察 → 再思考"的闭环(ReAct 模式)。

3.2 一次完整对话的时序(以"广州天气怎么样,适合出门吗"为例)

sequenceDiagram participant U as 用户 participant A as Agent 主循环 participant L as 大模型 participant T as WeatherTool participant W as wttr.in API U->>A: 广州天气怎么样,适合出门吗 A->>L: messages + 10个工具的Schema L-->>A: 决定调用 weather_query(city="广州") A->>T: execute("weather_query", {city:"广州"}) T->>W: GET https://wttr.in/广州?format=j1 W-->>T: 33°C / 晴 / 湿度48%... T-->>A: 结构化天气JSON + 出行建议 A->>L: tool结果回传(role=tool) L-->>A: "广州现在33°C,晴天,注意防晒补水🌞" A-->>U: 最终回复(写入记忆,可语音播报)

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:

  1. "帮我创建文件 notes.txt,内容是:今天也要加油"被时间意图误拦截(正则 今天.* 过宽)→ 改为精确匹配"今天几号/星期几";
  2. 0.1+0.2 输出 0.30000000000000004 → 格式化时先消除浮点尾差;
  3. "今天天气怎么样"把"今天"当成城市名 → 增加非城市词黑名单;
  4. 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:

![网页聊天界面](docs/web-chat.png)

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

![工具卡片展开效果](docs/web-tool-card.png)

### 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

十、总结

这次作业让我们完整走通了智能体开发的三大核心问题:

  1. 怎么让 LLM"会做"——Function Calling:把工具的 JSON Schema 告诉模型,模型决定何时调用什么参数,程序负责真实执行并回传结果;
  2. 怎么让智能体"标准可接入"——MCP 协议:同一套工具通过标准协议暴露给任何 MCP 客户端(Claude Desktop/Cursor),工具一次编写、生态共享;
  3. 怎么让系统"始终可演示"——接口抽象 + Mock 兜底:LlmClient 接口隔离了真实 API 与本地规则引擎,核心循环不依赖任何外部服务。

智能体不是玄学,它就是 LLM + 工具 + 循环。当这三个零件在终端里第一次同时转动起来的那一刻,我们感觉自己摸到了下一代智能应用开发的门把手。这份经验和这套代码骨架,会成为我们大项目的起点。

posted @ 2026-10-23 21:33  2253  阅读(6)  评论(0)    收藏  举报