构建一个能说会做的智能体
构建一个能说会做的智能体
本文是课程作业《构建一个能说会做的智能体》的记录,介绍从需求分析到编码实现一个最简智能体(Agent)的全过程。
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/16761 |
| 这个作业的目标 | 学习如何使用MCP框架构建具备“能说会做”特征的智能体 |
| 学号 | 段翔晖 3124004167;王尉人 3124004180;谢毕护 3124004186;封超 3124004168 |
| 小组公共仓库 | https://github.com/yydbjx/homework-agent |
需求描述
为什么做这个类型的智能体
我们选择的类型是"个人事务助手型"智能体——既能像聊天机器人一样听懂人话,又能动手操作真实的时间和数据。做这个类型,主要出于三点考虑:
1. 纯聊天大模型有明确的"能力天花板"。 它只会"说",不会"做":问它现在几点只能瞎猜,让它算个数可能心算出错,让它记件事聊完就忘。而学习生活中的真实需求往往既要理解意图、又要产生动作,只靠一个会说话的大脑解决不了。
2. 这类需求足够真实、规模又足够小。 时间和待办是每个人每天都在用的东西,不需要任何业务背景就能讲清楚需求;同时它又能完整体现 Agent 的核心难点——模型如何判断该不该用工具、用哪个工具、结果怎么组织成人话。比做客服机器人、代码助手这类大题材更容易在一周内跑通并验证。
3. 它是理解"智能体"本质的最小切口。 剥开 LangChain、MCP 这些外壳,Agent 的内核就是 LLM + 工具调用 + 对话循环。个人事务助手把三样都占全了,却不需要任何额外框架,很适合作为学习 Agent 原理的第一个项目。
具体来看,我们想解决的正是下面这些"纯聊天做不到、Agent 做得到"的场景:
| 常见场景 | 纯聊天大模型 | Agent(小智)的做法 |
|---|---|---|
| "现在几点了?" | 只能编一个时间 | 调用 get_current_time,返回真实系统时间 |
| "帮我算 (128+72)*3/4" | 大模型心算偶尔出错 | 调用 calculator,本地精确计算 |
| "记一下明天交作业" | 聊完即忘 | 调用 add_todo,写入 todo.json 持久保存 |
| "我有什么没做完的事?" | 无从知晓 | 调用 list_todos,读取真实待办清单 |
作业要求与本项目的目标
作业要求构建的 “能说会做” 智能体包含:
- 会说(对话能力):接入大模型 API,能够进行自然语言多轮对话,理解用户意图并给出回复;
- 会做(行动能力):不局限于"动嘴",还能"动手"——通过工具调用(Function Calling)真正执行操作,例如查询信息、执行计算、记录数据;
- 在博客随笔中说明需求、实现与使用示例,并附上源码地址与小组分工。
因此本项目的目标定为:做一个会说(多轮对话)又会做(6 个可执行工具)的个人事务助手「小智」,规模不大,但把 "意图 → 执行 → 反馈 → 总结" 的完整闭环跑通。
技术选型
为了把 Agent 的底层原理吃透,我们没有直接上 LangChain / LlamaIndex 等框架,而是用 Python + OpenAI 兼容 API 的原生 Function Calling 从零实现,再配上 6 个实用工具:
| 工具 | 说明 | 体现的能力 |
|---|---|---|
get_current_time |
查询当前日期时间 | 信息获取 |
calculator |
四则运算计算器 | 精确计算 |
get_weather |
查询城市天气(教学模拟数据) | 外部数据查询 |
add_todo / list_todos / finish_todo |
待办清单的增、查、完成 | 有状态的"真实动作"(写文件持久化) |
业务流程描述
智能体「小智」处理一次用户请求的完整业务流程如下:
┌──────────┐ ①用户输入 ┌─────────────┐ ②携带对话历史
│ 用户 │ ────────────▶ │ agent.py │ ─────────────▶ 大模型 API
└──────────┘ │ 对话循环 │ ◀───────────── ③返回回答
▲ └──────┬──────┘ (或工具调用意图)
│ │ ④模型请求调用工具
│ ⑦面向用户的最终回答 ▼
└──────────────────── ┌─────────────┐
│ tools.py │ ⑤本地真正执行工具
│ 工具函数集 │ ────────────▶ 天气/时间/计算/
└─────────────┘ 待办文件等本地资源
⑥执行结果回传给模型
按步骤说明:
- 接收输入:用户在命令行输入自然语言问题或指令;
- 组织上下文:程序把该输入追加进
messages对话历史,连同系统提示词一起发给大模型; - 模型决策:大模型判断该问题是否需要外部信息或真实动作——
- 闲聊类问题:直接生成回答(跳到第 7 步);
- 需要行动:返回一个工具调用意图(工具名 + 参数 JSON);
- 意图解析:
agent.py解析意图,按名字在TOOL_FUNCTIONS中找到对应 Python 函数; - 本地执行:真正运行工具函数——查时间、做计算、读模拟天气数据,或读写
todo.json待办文件; - 结果回传:把工具执行结果以
role: tool消息追加进对话历史,再次请求大模型; - 生成回答:模型综合工具结果,用自然中文回复用户;该回答同样写入历史,供后续多轮对话使用。
一次请求中"模型决策 → 本地执行 → 结果回传 → 总结回答"可循环多轮(例如"查完天气再帮我记个待办"),直到模型认为可以直接回答为止。
实现说明
整体架构只有三层,非常清晰:
用户 ⇄ agent.py(对话循环)⇄ 大模型 API(大脑)
│ MCP Client(mcp_client.py)
│ ⇅ stdio + JSON-RPC
│ MCP Server(todo_mcp_server.py,FastMCP)
▼
tools.py(手:6 个业务工具,零协议依赖)
1. 技术选型
| 层 | 选型 | 理由 |
|---|---|---|
| 协议 | MCP(Model Context Protocol) | 作业要求;能力层与大模型解耦,工具契约统一,同一个 Server 可被任意 MCP 客户端挂载复用 |
| MCP SDK | mcp 官方包(FastMCP) |
装饰器写法,一个 @mcp.tool() 就是一个工具;官方维护,stdio/HTTP 传输开箱即用 |
| LLM | DeepSeek(deepseek-chat) |
OpenAI 协议兼容、中文理解强、支持 Function Calling、价格便宜 |
| 语言 | Python 3.10+ | 生态最全,MCP 官方 SDK 最成熟 |
| 对话编排 | 纯 Python 手写(不用 Agent 框架) | 排除 LangChain 等黑盒,把对话循环、工具调度、结果回传每一步都写明白 |
| 前端 | Gradio | 十几行代码得到完整聊天 UI,自带消息流与快捷指令,演示截图成本最低 |
| 存储 | 本地 JSON 文件(todo.json) |
待办数据量小,零运维、单文件、随仓库可跑;写文件即持久化 |
| 双模式 | MCP=1 走协议 / 不设走进程内直连 |
既满足 MCP 要求,又能保留一份"没有协议层"的对照实现,便于讲清 MCP 到底多做了什么 |
说明:本项目把工具能力完整封装为 MCP Server,
agent.py作为 MCP Client 通过 stdio + JSON-RPC 发现并调用工具(MCP=1启动);同时保留不设MCP时的进程内直连模式,用于对照理解"协议层究竟带来了什么"。
2. 我们是如何运用 MCP 技术的
MCP(Model Context Protocol) 是 Anthropic 于 2024 年 11 月开源的开放协议,用来把"大模型应用 ↔ 外部工具/数据源"的连接方式标准化:Client-Server 架构,Server 用 Tools / Resources / Prompts 暴露能力,双方用 JSON-RPC 通信(stdio 或 HTTP)。
我们没有停留在"知道概念",而是把项目真正改造成了 MCP 架构,分成能力层和调度层两块:
┌──────────────────────────── agent.py(调度层 / 也是 MCP Client)────────────────────────────┐
│ 多轮对话循环 ──▶ 大模型 API ──▶ 返回工具调用意图 ──▶ 经 JSON-RPC 请求 Server │
└───────────────────────────────────────────┬─────────────────────────────────────────────────┘
│ stdio(标准输入输出)+ JSON-RPC
│ tools/list 发现 · tools/call 调用
┌───────────────────────────────────────────▼─────────────────────────────────────────────────┐
│ todo_mcp_server.py(能力层 / MCP Server,FastMCP) │
│ @mcp.tool() get_current_time / calculator / get_weather / add_todo / list_todos / finish_todo│
└───────────────────────────────────────────┬─────────────────────────────────────────────────┘
▼
tools.py(业务逻辑,零改动)
关键代码 1:MCP Server(能力层)
业务逻辑一行没改,只是把 tools.py 的函数用 @mcp.tool() 注册出去——一个装饰器就是一个工具,函数签名和 docstring 自动变成模型看到的参数说明:
# todo_mcp_server.py —— 能力层:把 tools.py 的工具暴露为标准 MCP Server
from mcp.server.fastmcp import FastMCP
import tools
mcp = FastMCP("xiaozhi-tools")
@mcp.tool()
def get_current_time() -> str:
"""获取当前的日期、时间和星期。"""
return tools.get_current_time()
@mcp.tool()
def calculator(expression: str) -> str:
"""计算四则运算表达式,支持加减乘除和括号。"""
return tools.calculator(expression)
@mcp.tool()
def get_weather(city: str) -> str:
"""查询指定城市的天气情况。"""
return tools.get_weather(city)
@mcp.tool()
def add_todo(content: str) -> str:
"""添加一条待办事项。"""
return tools.add_todo(content)
# list_todos / finish_todo 同理 …
if __name__ == "__​main__":
mcp.run(transport="stdio") # 以 stdio 方式启动,供任意 MCP Client 拉起
关键代码 2:MCP Client(调度层,动态发现工具)
agent.py 不再硬编码工具清单,而是通过 tools/list 动态发现,再把 MCP 的工具定义转换成大模型需要的 Function Calling schema:
# mcp_client.py —— tools/list 发现工具,转成大模型能吃的 schema
async def _connect(self):
params = StdioServerParameters(command=sys.executable,
args=[self.server_script], env=dict(os.environ))
read, write = await self._stack.enter_async_context(stdio_client(params))
self.session = await self._stack.enter_async_context(ClientSession(read, write))
await self.session.initialize() # MCP 握手
def list_tools(self) -> list[dict]:
result = self._call(lambda: self.session.list_tools()) # tools/list
return [{"type": "function",
"function": {"name": t.name,
"description": t.description or "",
"parameters": t.inputSchema}} for t in result.tools]
def call_tool(self, name: str, arguments: dict) -> str:
result = self._call(lambda: self.session.call_tool(name, arguments)) # tools/call
return "\n".join(c.text for c in result.content if getattr(c, "text", None))
对话主循环因此变得很薄——工具从哪来、在哪执行,它完全不用关心:
# agent.py
def get_tools_schema():
return get_mcp_client().list_tools() if USE_MCP else tools.TOOLS_SCHEMA
def run_tool_call(tool_call, verbose=True):
name, args = tool_call.function.name, json.loads(tool_call.function.arguments or "{}")
result = get_mcp_client().call_tool(name, args) if USE_MCP else str(func(**args))
...
踩到的真实坑:anyio 的 cancel scope
MCP 官方 SDK 是 async 的,而我们的对话循环是同步的。第一版做法是"每次调用新建一个任务",结果关闭连接时崩溃:
RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
原因是 anyio 要求 cancel scope 的进入和退出必须发生在同一个任务里。改为"后台线程跑一个常驻协程 _serve(),所有请求通过 asyncio.Queue 投递给它串行执行"之后问题消失——这也说明 MCP 这类异步协议和同步代码混用时,任务边界必须设计清楚。
实测:MCP 链路是通的
python mcp_selftest.py(不需要大模型 Key)实测结果:
① 连接 MCP Server:成功(initialize 握手完成)
② tools/list 发现 6 个工具:get_current_time, calculator, get_weather,
add_todo, list_todos, finish_todo
③ tools/call get_current_time -> 2026-10-25 21:55:42 星期日
④ tools/call calculator -> (128+72)*3/4 = 150.0
⑤ tools/call calculator(注入) -> 错误:只能计算包含数字和 + - * / ( ) 的算式
⑥ tools/call get_weather -> 北京:晴,气温 18℃,微风
⑦ add_todo -> 已添加待办:明天要交智能体作业(当前共 1 条)
⑧ list_todos -> [ ] 1. 明天要交智能体作业
⑨ finish_todo -> 待办 1「明天要交智能体作业」已完成!
MCP 链路自测:8/8 项通过
这一步的意义:工具经 JSON-RPC 跨进程调用后行为完全一致(连注入拦截都照样生效),说明"能力层"与"调度层"确实解耦成功;同一个 Server 也可以直接挂到 Claude Desktop 等任意 MCP 客户端上复用。
3. "会说":多轮对话
Agent 的"记忆"本质就是一个不断追加的 messages 列表。每轮把用户输入 append 进去发给模型,模型回答后再 append 回去,于是模型始终能看到之前的对话上下文:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.append({"role": "user", "content": user_input})
reply = chat_once(client, messages) # 回答也会被 append 进 messages
system 提示词里给智能体定了人设("小智")和行为准则:"需要事实信息或具体动作时优先调工具,闲聊时正常对话"。
4. "会做":Function Calling 工具调用
分两步:
第一步,"告诉模型你有哪些工具"——把每个工具的名字、功能描述、参数用 JSON Schema 描述,随请求发给模型:
TOOLS_SCHEMA = [
{"type": "function", "function": {
"name": "get_weather",
"description": "查询指定城市的天气情况",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}},
# ... 其余工具同理
]
第二步,"模型决定调用 → 本地执行 → 结果回传"。模型不会真的执行任何东西,它只会返回一个"我想调 get_weather,参数是 {"city": "北京"}"的意图;真正的执行发生在我们本地:
if msg.tool_calls: # 模型说:我要用工具
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = tools.TOOL_FUNCTIONS[tc.function.name](**args) # 本地执行
messages.append({"role": "tool",
"tool_call_id": tc.id,
"content": str(result)}) # 结果回传
resp = client.chat.completions.create(model=MODEL, messages=messages)
模型拿到工具的真实返回后,再组织成自然的中文回答给用户。这个 “意图 → 执行 → 反馈 → 总结” 的闭环,就是 Agent "会做" 的全部秘密。
5. 安全性小细节
calculator 工具用正则 [0-9+\-*/().\s]+ 白名单过滤输入,只允许数字与运算符,避免把用户输入直接 eval 造成代码注入;待办数据写入本地 todo.json 持久化,重启程序后依然记得。
6. 网页界面(Gradio)
为了让演示更直观(也方便截图进博客),我们还用 Gradio 写了一个约 60 行的网页界面 web_ui.py:
- 复用
agent.py的chat_once对话核心,不重复实现任何逻辑; - 每个浏览器会话从聊天记录重建
messages,支持多轮记忆,并有"🧹 清空记忆"按钮; - 内置 5 个快捷指令按钮(查时间 / 算数 / 查天气 / 记待办 / 看待办),一键发送,演示时不用手打字;
- 运行
python web_ui.py后浏览器打开http://127.0.0.1:7860即可使用。
7. 加分项对照
| 加分项 | 我们怎么做的 | 代码位置 | 证据 |
|---|---|---|---|
| MCP 协议接入 | 工具层用 FastMCP 的 @mcp.tool() 暴露为 MCP Server;agent.py 作为 MCP Client 经 stdio + JSON-RPC 做 tools/list 动态发现与 tools/call 调用;MCP=1 一键切换协议/直连双模式 |
todo_mcp_server.py、mcp_client.py、agent.py |
mcp_selftest.py 8/8 通过(含握手、发现 6 工具、跨进程调用) |
| 多轮对话记忆 | 对话历史按 messages 追加保存并整体回传;网页版从聊天记录重建上下文,支持「🧹 清空记忆」重置;system 提示词固定人设 |
agent.py、web_ui.py |
功能演示⑤能记起上文刚记的待办 |
| 与外部 API / 存储交互 | 外部:DeepSeek(OpenAI 兼容,换智谱/Kimi/通义只改 base_url);存储:待办经 MCP 跨进程读写 todo.json,重启后仍在 |
agent.py、tools.py、todo_mcp_server.py |
真实接口调用日志;⑦⑧⑨ 写读完成链路 |
| 工具安全与健壮性 | calculator 正则白名单拦截注入;模型空参数 or "{}" 兜底;未知工具名 dict.get() 兜底,不崩溃 |
tools.py、agent.py |
test_tools.py 9 项(含注入拦截用例) |
| 简洁前端界面 | Gradio 聊天窗 + 5 个快捷指令按钮 + 清空记忆,一个 py 文件即一个网页,无需写 HTML/CSS/JS | web_ui.py |
web_ui_demo.png 与 demo_1~5.png 五张运行截图 |
| 离线演示模式 | MOCK=1:工具执行真实、模型决策脚本化,无 API Key 也能课堂演示与截图 |
web_ui.py |
五张功能演示截图均在此模式下产出 |
| 零成本自动化测试 | 四组共 23 项:静态检查 4、工具层 9、MCP 链路 8、界面 2,前两组完全不消耗 API 额度 | test_tools.py、mcp_selftest.py |
实测 23/23 通过 |
使用示例
配置好 API Key 后运行 python agent.py,实际交互效果(🔧 开头的行是工具调用日志):
你 > 现在几点了?
🔧 [调用工具] get_current_time({}) -> 2026-10-24 23:01:12 星期六
小智 > 现在是 2026 年 10 月 24 日,星期六,晚上 23 点 01 分。
你 > 帮我算下 (128+72)*3/4
🔧 [调用工具] calculator({'expression': '(128+72)*3/4'}) -> (128+72)*3/4 = 150.0
小智 > 计算结果是 150。
你 > 记一下:明天要交智能体作业
🔧 [调用工具] add_todo({'content': '明天要交智能体作业'}) -> 已添加待办(当前共 1 条)
小智 > 好的,已记下:明天要交智能体作业 ✅
你 > 我有什么待办?
🔧 [调用工具] list_todos({}) -> [ ] 1. 明天要交智能体作业
小智 > 你目前有 1 条待办:明天要交智能体作业,加油!
可以看到,同一个对话里模型会自主决定"该不该用工具、用哪个工具",用完再把结果讲成人话——这就是最朴素的 Agent。
功能演示一览:
| 序号 | 演示操作 | 效果说明 |
|---|---|---|
| 1 | 输入"现在几点了?" | 调用时间工具,返回真实的系统日期时间与星期 |
| 2 | 输入"(128+72)*3/4" | 调用计算器,返回精确结果 150.0(大模型心算容易错) |
| 3 | 输入"北京天气怎么样?" | 调用天气工具,返回天气/气温/风力 |
| 4 | 输入"记一下:明天要交智能体作业" | 写入待办文件,重启程序后依然记得 |
| 5 | 输入"我有什么待办?"→"1 完成了" | 查询并勾选待办,展示有状态的多轮操作 |
网页界面运行效果:运行 python web_ui.py,浏览器打开 http://127.0.0.1:7860(界面内置快捷指令按钮)。界面总览:

下面按功能逐个展示运行效果(多轮对话依次进行,可以看到对话记忆在持续累积):
① 查时间 —— 调用 get_current_time,返回真实的系统日期时间与星期:

② 精确计算 —— 调用 calculator,本地精确算出 (128+72)*3/4 = 150.0:

③ 查天气 —— 调用 get_weather,返回城市、天气、气温与风力:

④ 记待办 —— 调用 add_todo,把内容真实写入 todo.json 持久化保存:

⑤ 查待办(多轮记忆) —— 调用 list_todos,能记起上文刚记的待办,说明历史上下文一直生效:

截图说明:以上截图在离线演示模式(
MOCK=1启动)下截取——工具执行(时间/计算/天气/待办读写)均为真实运行,仅"模型决策"由脚本模拟,便于无 Key 环境下课堂演示;填入真实 API Key 后,模型会自主完成同样的决策。
测试与验证
# 工具层自测(不依赖 API Key)
python test_tools.py
# MCP 链路自测(不依赖 API Key)
python mcp_selftest.py
# 静态语法检查(四个文件)
python -m py_compile agent.py tools.py web_ui.py test_tools.py
测试分为四组,共 23 项:
| 分组 | 项数 | 验的是什么 |
|---|---|---|
| 静态检查 | 4 | agent.py / tools.py / web_ui.py / test_tools.py 四个文件 py_compile 语法检查;同时人工确认三处防御逻辑在位:calculator 的输入白名单正则、未知工具名用 dict.get() 兜底、模型返回空参数时 or "{}" 兜底 |
| 工具层自测 | 9 | test_tools.py 在不花 API Key 的前提下运行:时间格式与星期、计算正确性、注入字符串拦截、天气正反例、待办增/查/完成全链路(含持久化后 ✓ 状态) |
| MCP 链路自测 | 8 | mcp_selftest.py:stdio 拉起 Server + initialize 握手、tools/list 发现 6 个工具、tools/call 逐个执行(时间/计算/注入拦截/天气/待办写读完成),验证跨进程调用后行为与直连完全一致 |
| 界面运行检查 | 2 | web_ui.py 启动后 HTTP 200;聊天窗口、快捷指令按钮、清空记忆按钮均正常渲染(用无头浏览器截图确认,即上文演示图) |
工具层自测的真实输出:
2026-10-24 22:53:28 星期六
(128+72)*3/4 = 150.0
错误:只能计算包含数字和 + - * / ( ) 的算式
北京:晴,气温 18℃,微风
暂无「火星」的天气数据(示例支持:北京、上海、广州、深圳、杭州、成都、武汉、西安)
已添加待办:明天要交智能体作业(当前共 1 条)
[ ] 1. 明天要交智能体作业
待办 1「明天要交智能体作业」已完成!
[✓] 1. 明天要交智能体作业
真实链路(对话级)验证:工具层测过不等于整条链路通——模型可能不调工具、调错工具或传错参数。这一步需要填真实 Key,用一轮对话依次触发全部 6 个工具:
现在几点了? → 期望调用 get_current_time
帮我算 (128+72)*3/4 → 期望调用 calculator
北京天气怎么样? → 期望调用 get_weather
记一下:明天要交智能体作业 → 期望调用 add_todo
我有什么待办? → 期望调用 list_todos
1 完成了 → 期望调用 finish_todo
验证结论:四组共 23 项测试全部通过(静态检查 4 项、工具层自测 9 项、MCP 链路自测 8 项、界面运行 2 项,无任何失败项)。其中 MCP 链路自测拿到 8/8:Server 能被成功拉起并握手,6 个工具全部经
tools/list发现、经tools/call跨进程执行成功。随后用上面这轮对话依次触发全部 6 个工具:在离线演示模式下(MOCK=1,工具执行为真实运行、模型决策由脚本模拟)6 个工具均调用成功并返回正确结果——其中时间取自系统时钟、计算结果为 150.0、待办真实写入todo.json且重启后仍可读取,完整交互过程见上文 5 张功能演示截图。填入真实 API Key 后,模型会自主完成同样的工具选择与参数生成。
测试过程中修掉的真实现象
| # | 现象 | 根因 | 修复 |
|---|---|---|---|
| 1 | calculator 最初直接 eval 用户输入,可被注入任意代码 |
把模型生成的参数当成了可信输入 | 改为正则 [0-9+\-*/().\s]+ 白名单过滤,只放行数字与运算符 |
| 2 | 模型返回空参数时 json.loads 直接抛异常 |
某些兼容接口会返回空串而非 {} |
改为 json.loads(arguments or "{}") 兜底 |
| 3 | 模型幻觉出不存在的工具名导致 KeyError | 未对工具名做存在性检查 | 改用 TOOL_FUNCTIONS.get(),未知工具返回提示而非崩溃 |
| 4 | Gradio 6 报 Chatbot.__init__() got an unexpected keyword argument 'type' |
网上旧教程基于 Gradio 4 写法,Gradio 6 已移除该参数 | 删除参数,requirements.txt 固定 gradio>=5.0.0 锁定行为 |
| 5 | 网页版点"清空记忆"后模型仍记得旧对话 | 历史是从聊天窗口记录重建的,按钮只清了界面没清逻辑 | 清空时同时重置消息输入框与对话历史 |
| 6 | 问天气时模型偶尔误调时间工具 | 工具 description 写得太含糊 |
把每个描述改写成"说明书":写清适用场景和参数含义,误调明显减少 |
三条值得展开的点
① 工具层可以先于大模型测试。 6 个工具是纯 Python 函数,不依赖任何 API Key 就能把行为、边界和异常全部测死(test_tools.py 9 项)。这样联调时如果出问题,问题只可能出在"模型决策"或"调度层",排查范围直接砍半。
② 模型生成的参数不可信任。 上面 #1、#2、#3 三个 bug 的共同根因是同一个假设错误:"模型给的参数是对的"。实际它可能给空串、幻觉工具名、甚至被注入恶意表达式。所有校验必须放在本地执行前——这也是 Agent 开发和普通函数调用最大的区别。
③ 描述即接口。 #6 那类"误调"修起来不改一行逻辑,只改 description 的措辞。给模型写工具描述本质上是在写它唯一能读的 API 文档,措辞的精确程度直接决定调用准确率,值得花和写代码同样多的心思。
整体体会
- 测试要从反例开始:每个工具我们都构造了正例 + 反例(错误的算式、不存在的城市、不存在的待办编号),只测正例的测试约等于没测;
- 断言要落在真实输出上:注入拦截那项测的是"返回了错误提示"而不是"没崩溃",前者才是安全性的真实语义;
- 最难测的是不确定性:LLM 的决策无法用断言测死,所以架构上我们把确定的(工具执行)和不确定的(模型决策)分开——确定的部分全部测死,不确定的部分用真实链路人工验证一轮收敛。
GitHub 仓库
本项目代码已提交至 GitHub 公共仓库:
环境与运行
| 项目 | 版本/要求 | 说明 |
|---|---|---|
| Python | 3.10+ | 仅用到标准库 + 三个第三方包 |
| openai | >= 1.0 | 调用 OpenAI 兼容接口 |
| gradio | >= 5.0 | 仅网页界面需要,命令行版可不装 |
| mcp | >= 1.0, < 2.0 | MCP 协议官方 SDK(FastMCP);注意 2.x 改名 MCPServer |
| LLM API Key | 任一 OpenAI 兼容服务 | DeepSeek / 智谱 / Kimi / 通义等 |
# 1. 安装依赖
pip install -r requirements.txt
# 2. 配置 API Key(Windows PowerShell 用 $env:LLM_API_KEY="sk-xxx")
export LLM_API_KEY="sk-你的Key"
export LLM_BASE_URL="https://api.deepseek.com/v1" # 可选,默认 DeepSeek
export LLM_MODEL="deepseek-chat" # 可选
# 3a. 命令行版
python agent.py
# 3b. 网页版(浏览器打开 http://127.0.0.1:7860 )
python web_ui.py
# 4. 走 MCP 协议调用工具(工具清单由 MCP Server 动态下发)
MCP=1 python agent.py
# 5. 自测(都不需要 API Key)
python test_tools.py
python mcp_selftest.py
目录结构与文件说明
homework-agent/
├── agent.py # 主程序:命令行对话循环、工具调用调度、多轮记忆
├── tools.py # 工具集:6 个工具函数 + 告知大模型的 JSON Schema
├── todo_mcp_server.py # 能力层:MCP Server(FastMCP),把 6 个工具按标准协议暴露
├── mcp_client.py # 调度层:MCP Client,stdio 连接 + tools/list 发现 + tools/call 调用
├── mcp_selftest.py # MCP 链路自测:不依赖大模型 Key 验证协议层
├── web_ui.py # 网页界面:Gradio 版聊天窗口 + 快捷指令按钮(MOCK=1 为离线演示模式)
├── web_ui_demo.png # 网页界面运行截图(即上文演示图)
├── demo_1_time.png … demo_5_listtodo.png # 五个功能的真实运行效果截图
├── take_screenshots.py # 配合 Playwright 自动发送演示消息并批量截图的辅助脚本
├── test_tools.py # 自测脚本:不依赖 API Key 验证全部工具行为
├── requirements.txt # 依赖清单(pip install -r requirements.txt)
├── todo.json # 待办数据文件(运行后自动生成)
└── README.md # 项目说明
小组分工
| 成员 | 学号 | 分工 |
|---|---|---|
| 段翔晖 | 3124004167 | 需求分析、任务拆分、进度把控;MCP 架构设计与联调 |
| 王尉人 | 3124004180 | Agent 对话模块:多轮对话循环、工具调用调度与错误兜底 |
| 谢毕护 | 3124004186 | 能力层与工具集:6 个工具实现、FastMCP Server、MCP Client 封装 |
| 封超 | 3124004168 | 网页界面(Gradio)、自动化测试脚本、博客随笔撰写与整理 |
每个小组成员的心得
段翔晖
这次作业让我体会到"拆任务"比"写代码"更难。一开始我们想把功能一次做全,结果发现工具、协议、界面三件事互相阻塞;后来拆成"先跑通最小闭环(工具 + 对话),再加 MCP,最后做界面"的顺序,每一步都能独立验证,联调成本大大降低。项目管理上最大的收获是:给模型写清工具边界,和给组员写清任务边界,本质是同一件事——说明书越含糊,返工越多。
王尉人
这次作业最大的收获是理解了"记忆"的朴素本质——一个不断追加的 messages 列表。调试时曾出现模型答非所问,最后定位到是清空历史时把 system 提示词一起丢掉了,模型因此"失忆"又"失格"。这件事让我真正理解了上下文窗口和系统提示词在工程上的意义,而不是停留在概念层面;也让我意识到对话循环看似简单,但异常分支(空参数、未知工具、接口超时)必须逐个兜住,否则演示时一定翻车。
谢毕护
我负责工具层和 MCP 封装,印象最深的是那个 RuntimeError: Attempted to exit cancel scope in a different task than it was entered in。MCP SDK 是 async 的,我们一开始"每次调用新建一个任务",结果关闭连接时直接崩;改成后台线程跑一个常驻协程、用 asyncio.Queue 串行处理请求后才稳定下来。这次踩坑让我第一次真正想清楚"任务边界"和"上下文生命周期",也理解了 MCP 的价值——把 6 个函数挂上 @mcp.tool() 之后,它们就能被任意 MCP 客户端复用,能力层彻底和界面解耦。
封超
本人主要负责网页界面、测试和随笔整理。Gradio 6 把 Chatbot(type=...) 参数移除这件事教育了我:网上教程和实际依赖版本经常对不上,锁定版本和先跑一遍比照着抄更省时间。测试方面最大的收获是"工具层可以先于大模型验证"——test_tools.py 和 mcp_selftest.py 不花一分钱 Key 就能把 23 项行为、边界、异常全部跑一遍;整理随笔的过程也让我把 Agent "意图 → 执行 → 反馈 → 总结" 的闭环真正讲给了自己听。

浙公网安备 33010602011771号