构建一个能说会做的智能体
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 小组作业:智能体 |
| 这个作业的目标 | 用现代 AI 工具构建一个既"能说"(自然语言对话)又"会做"(调用工具真正执行)的智能体 |
| 学号 | 3124004477(刘浩彬)、3124004465(邓家俊)、3124004483(曲浩宾)、3124004474(李忠林) |
办公小助手:一个"能说会做"的智能体
代码地址:https://github.com/Siruetto/liuhaobin (本次作业在 AgentProject/ 目录下)
一句话介绍:用一句自然语言指挥它干活——算数、记待办、读写文件、查天气、看时间。
它不是"告诉你怎么做",而是真的去做,做完再用人话汇报。
项目规模:9 个工具、10 个 Python 模块、51 个单元测试、1 个 MCP 服务、1 个网页界面,
不依赖任何第三方库。
一、需求描述
1.1 要解决的麻烦
日常办公的琐事散落在很多地方:算个数要开计算器,记事情要开备忘录,改个文件要开编辑器,
查天气要开浏览器,看时间要掏手机。每件事都很小,但来回切换很烦,而且经常忘。
我们想要的是:一个入口,一句话,剩下的它自己办。
1.2 目标与底线
- 能说:支持多轮对话,记得住上下文。比如先问"北京天气",再问"那上海呢",
它要知道你在问天气。 - 会做:每个回答背后要么有一次真实的工具调用,要么明确说"这个我还不会"。
宁可承认不会,也不许编。 - 可扩展:加一个新工具只改一个注册表,规划器、MCP、网页、测试都不用动。
- 能离线演示:没配 API Key、没有外网也能跑完整链路。
这一条是实际做的时候被逼出来的——演示现场不一定有网。
1.3 功能清单
| 工具 | 能力 | 典型说法 | 失败时的表现 |
|---|---|---|---|
calculator |
四则运算、括号、乘方 | "帮我算一下 (3+5)*2" | 除零、语法错、指数过大都给出具体原因 |
todo_add |
添加待办并落盘 | "记一下 周五前交周报" | 内容为空时拒绝 |
todo_list |
查看待办和完成状态 | "看看待办" | 空清单给友好提示 |
todo_done |
按序号标记完成 | "完成第 2 条" | 序号越界时说明没有这一条 |
file_write |
在沙箱目录写文件 | "把 hello 写到 notes/a.txt" | 路径越界、内容过大都拒绝 |
file_read |
读沙箱目录里的文件 | "读一下 notes/a.txt" | 文件不存在时说明路径 |
file_list |
列出沙箱里的文件 | "有哪些文件" | 空目录给提示 |
weather |
查实时天气(Open-Meteo) | "北京天气怎么样" | 网络不可用时变成一句人话 |
current_time |
当前日期时间 | "现在几点" | —— |
1.4 非功能要求
- 零依赖:只用 Python 标准库(
urllib、http.server、subprocess、ast、json),
拷到任何装了 Python 3.7+ 的机器上就能跑; - 安全:计算器不用
eval,文件工具只能碰workspace/; - 可测:所有外部依赖(网络、时间)都能被 mock,测试不需要联网。
二、业务流描述
2.1 整体流程
用户一句话
│
▼
Agent.chat()
│ 取出最近 20 轮对话
▼
规划器(二选一)
├── 有 API Key ──► LLMPlanner:把工具 Schema 一起发给模型,
│ 模型通过 function calling 决定调什么
└── 没有 Key ───► OfflinePlanner:用正则把常见说法映射成工具调用
│
▼ 得到 (工具名, 参数)
ToolBox.call(name, arguments)
├── 本地:直接调用 Python 函数
└── MCP :写一行 JSON-RPC 到子进程,读一行结果回来
│
▼ 工具返回一段字符串
规划器组织成自然语言
│
▼
Memory.add(user) / Memory.add(assistant) / Memory.save()
│
▼
命令行打印 或 网页返回 JSON
2.2 一次完整调用的时序
以"把 明天带伞 写到 notes/todo.md"为例:
| 步骤 | 发生了什么 | 数据 |
|---|---|---|
| 1 | 用户输入 | 把 明天带伞 写到 notes/todo.md |
| 2 | Agent.chat() 从记忆里取出历史 |
[{role:user,...}, {role:assistant,...}, ...] |
| 3 | 规划器识别意图 | tool=file_write |
| 4 | 提取参数 | path=notes/todo.md, content=明天带伞 |
| 5 | 工具解析路径 | workspace/notes/todo.md,校验没有 ../、不是绝对路径 |
| 6 | 真正写盘 | 写入 12 字节 |
| 7 | 返回结果 | 已写入 notes/todo.md (12 字节) |
| 8 | 写回记忆并回复 | 历史里多出两条,用户看到这句话 |
网页界面上同时会显示一行 "工具: file_write ✓"——用户能看见它到底做了什么。
2.3 出错时的时序
如果用户说"把 秘密 写到 ../secret.txt":
- 规划器照样提取出
path=../secret.txt; file_write在解析路径时发现越界,抛ToolError("只能操作 workspace/ 目录里的文件");- 规划器捕获它,把这句话变成回复;
- 用户看到"工具出错: 只能操作 workspace/...",程序不崩,记忆照样记录这一轮。
关键点:错误不是靠提示词约束的,而是靠代码里的检查。模型可以提出任何参数,
能不能执行由工具层说了算。
三、实现说明
3.1 模块划分
| 文件 | 职责 | 主要接口 |
|---|---|---|
agent.py |
命令行入口 | main(argv) |
agent_core.py |
智能体主体 | Agent.chat()、Agent.reset() |
planner.py |
两种规划器 | LLMPlanner.respond()、OfflinePlanner.respond() |
llm.py |
OpenAI 兼容客户端 | chat(messages, tools) |
tools.py |
工具实现与注册表 | LocalToolBox.call()、tool_schemas() |
memory.py |
对话记忆 | Memory.add()、remember() |
mcp_server.py |
MCP 服务端 | handle()、stdio 主循环 |
mcp_client.py |
MCP 客户端 | MCPToolBox.call()、schemas() |
web_ui.py |
网页界面 | GET /、POST /api/chat |
config.py |
路径配置 | WORKSPACE_DIR、MEMORY_FILE |
依赖是单向的:
agent.py → agent_core.py → planner.py → tools.py → config.py
│
└→ llm.py(只在 LLMPlanner 里用)
agent_core.py → memory.py
mcp_client.py ──(子进程)──→ mcp_server.py → tools.py
web_ui.py → agent_core.py
planner.py 完全不关心工具在本地还是在子进程里跑,因为它只依赖一个 toolbox 对象;
本地实现和 MCP 实现都提供同样的 schemas() 和 call()。这是整个设计里最省事的一处。
3.2 工具层:一份定义,三处复用
每个工具在 tools.py 里只定义一次:
{
"name": "calculator",
"description": "计算数学表达式, 支持 + - * / // % ** 和括号。",
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "要计算的表达式"}
},
"required": ["expression"],
},
"handler": calculate,
}
这一份定义被三个地方消费:
def tool_schemas(): # 给大模型 function calling
return [{"type": "function", "function": {...}} for tool in TOOLS]
def mcp_tool_schemas(): # 给 MCP 的 tools/list
return [{"name": ..., "inputSchema": ...} for tool in TOOLS]
class LocalToolBox: # 给规划器直接调用
def call(self, name, arguments=None):
...
加一个"翻译"工具只要往 TOOLS 里加一项,模型、MCP、网页、测试自动都能用。
这是我们对"可扩展"这条要求的答案。
3.3 大模型模式:function calling 循环
大模型不是直接"回答问题",而是先决定调哪个工具。核心循环:
messages = [system_prompt] + memory.history() + [{"role": "user", "content": text}]
for _ in range(6): # 最多连续调 6 轮工具, 防止死循环
message = llm.chat(messages, tools=toolbox.schemas())
calls = message.get("tool_calls") or []
if not calls:
return message.get("content") # 模型觉得可以回答了
messages.append(message)
for call in calls:
name = call["function"]["name"]
arguments = json.loads(call["function"]["arguments"])
result = toolbox.call(name, arguments) # 真的执行
messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
几个细节:
- 最多 6 轮:模型可能反复调工具,加个上限防止烧 token;
- 工具错误也回填给模型:如果参数不对,模型能看到"工具出错: ...",下一轮自己改;
- 历史只带最近 20 轮:既保留上下文,又不会让 prompt 无限增长。
系统提示词里明确写了"能算的用 calculator,不要假装自己算过"——
因为模型很喜欢直接心算,尤其是大数,容易算错。
3.4 离线模式:规则规划器
没有 API Key 时走 OfflinePlanner。它不是要替代模型,而是保证离线也能把链路跑完整。
设计上有三点:
- 规则按"动词 + 宾语"组织:
记一下 X→todo_add,几点了→current_time,
把 X 写到 Y→file_write; - 顺序敏感:先匹配更具体的规则(比如"完成第 2 条"要先于"待办"),
否则会被宽泛规则抢走; - 保留上下文:记住上一次的意图,所以"北京天气"之后说"那上海呢"也能接上;
另外支持"我叫 X / 我叫什么"这种最简单的长期记忆。
它只认固定说法,说"今天心情不好"就会回一个能力清单。这是有意的取舍:
离线模式负责"能演示",自然语言理解交给大模型。
3.5 MCP:把工具放进程外面
MCP 的 stdio 传输就是"一行一条 JSON-RPC 消息":
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"calculator","arguments":{"expression":"2+3"}}}
服务端的处理就是一个分派函数:
def handle(request):
method = request.get("method")
if method == "initialize":
return _result(request_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "agent-tools", "version": "1.0.0"},
})
if method == "tools/list":
return _result(request_id, {"tools": mcp_tool_schemas()})
if method == "tools/call":
name, arguments = params["name"], params.get("arguments") or {}
text = TOOLS_BY_NAME[name]["handler"](**arguments)
return _result(request_id, {
"content": [{"type": "text", "text": text}],
"isError": False,
})
客户端把服务当子进程启动,写一行、读一行:
self.process = subprocess.Popen([sys.executable, "mcp_server.py"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE)
self.process.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
self.process.stdin.flush()
response = json.loads(self.process.stdout.readline().decode("utf-8"))
为什么用子进程:
- 符合 MCP 的设计——工具服务可以独立部署、独立重启;
- 有隔离,工具把进程搞挂了,主程序也只是收到"服务没有响应";
- 同一个服务可以被别的 MCP 客户端复用。
实测一次 MCP 往返(进程管道 + JSON 编解码)大约 0.03 毫秒,
而一次大模型调用通常是几百毫秒到几秒,这点开销可以忽略。
3.6 记忆
两层:
- 短期:最近 20 轮对话(
turns),每次请求带给模型; - 长期:键值事实(
facts,比如名字),存在data/memory.json,重启还在。
{
"turns": [
{"role": "user", "content": "我叫刘浩彬"},
{"role": "assistant", "content": "记住了, 你叫刘浩彬。"}
],
"facts": {"name": "刘浩彬"}
}
记忆写盘失败时只跳过保存,不影响这次对话——这是个刻意的取舍:
记忆是增强功能,不该让它把主流程拖死。
3.7 安全
计算器不用 eval。 用 ast.parse 拿到语法树,只认数字常量和四则运算节点:
def _eval_node(node):
if isinstance(node, ast.Constant):
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
raise ToolError("表达式里只能出现数字")
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPS:
...
raise ToolError("只支持数字和 + - * / // % ** 运算")
__import__("os").system("dir") 这种输入会直接报"只支持数字和四则运算"。
指数还限了上限(100),防止 9**9**9 把进程卡死。
文件工具只能碰 workspace/。 路径先解析成真实路径,再和根目录做 commonpath 比较:
root = os.path.realpath(WORKSPACE_DIR)
target = os.path.realpath(os.path.join(root, relative))
if os.path.commonpath([root, target]) != root:
raise ToolError("只能操作 workspace/ 目录里的文件")
../secret.txt、C:/Windows/x.txt 都会被拒绝。这样即使模型被诱导生成了危险路径,
也执行不了。
3.8 网页界面
web_ui.py 用标准库 http.server 起一个单页应用:
| 路由 | 作用 |
|---|---|
GET / |
返回聊天页面(HTML + CSS + 原生 JS) |
GET /api/status |
返回当前模式(llm / offline) |
POST /api/chat |
收 {message},返回 {reply, actions} |
前端用 fetch 调接口,并把 actions 渲染成"工具: file_write ✓"。
没有 React、没有打包工具,一个文件就能跑。
3.9 关键取舍
| 决策 | 放弃的方案 | 为什么这么选 |
|---|---|---|
| 两种规划器 | 只做大模型版 | 没有 Key/没有网时也要能演示,而且规则版倒逼我们把意图想清楚 |
| 工具定义只写一份 | LLM 和 MCP 各写一套 | 两套定义迟早会不一致;一份定义三处消费 |
| MCP 用子进程 | 全在本地调用 | 满足 MCP 的协议形态,还能隔离故障 |
| 计算器用 ast | 直接 eval |
eval 等于把执行任意代码的入口交给模型 |
| 记忆写失败只跳过 | 写失败就报错 | 记忆是增强功能,不该拖死主流程 |
| 大模型最多 6 轮工具 | 不设上限 | 防止模型反复调用烧 token |
四、测试与验证
4.1 用例分布
| 测试文件 | 用例数 | 覆盖内容 |
|---|---|---|
test_tools.py |
26 | 计算器(8)、待办(5)、文件(7)、天气(3)、时间(1)、工具注册表(2) |
test_planner.py |
12 | 帮助、算数、裸算式、待办增/查/完成、文件读/写/列、天气与追问、时间、记忆、兜底 |
test_mcp.py |
7 | 服务端 initialize / tools/list / tools/call / 未知工具 / 通知 / 未知方法 + 客户端子进程往返 |
test_agent.py |
6 | 端到端算数、待办往返、历史增长、事实落盘、重置、模式判断 |
| 合计 | 51 | 全部通过,0.6 秒跑完 |
4.2 几个测试的设计思路
沙箱测试:每个用例都在 tempfile.mkdtemp() 里跑,用 mock.patch 把
tools.WORKSPACE_DIR 和 tools.TODO_FILE 指过去,所以测试之间互不影响,
也不会污染真实数据。
天气测试:把 tools._http_get_json 换成假函数,一个返回地理编码、一个返回天气,
不联网也能验证"城市不存在""网络故障""数据格式"三种分支。
MCP 测试分两层:一层直接调 handle() 验证协议格式,
另一层真的 subprocess.Popen 起服务、发请求、读响应,验证管道和编码没问题。
端到端测试:Agent + OfflinePlanner + LocalToolBox 全串起来,
从一句话到最终回复,中间经过规划、工具、记忆、落盘。
4.3 手工验证矩阵
| 场景 | 输入 | 结果 |
|---|---|---|
| 算数 | 帮我算一下 (3+5)*2 |
16 |
| 危险表达式 | __import__("os").system("dir") |
拒绝:只支持数字和四则运算 |
| 除零 | 1/0 |
拒绝:除数不能为 0 |
| 指数爆炸 | 9**999 |
拒绝:指数请控制在 100 以内 |
| 路径穿越 | ../secret.txt |
拒绝:只能操作 workspace 目录 |
| 绝对路径 | C:/Windows/x.txt |
拒绝:请用相对路径 |
| 待办 | 记一下 买牛奶 → 看看待办 |
清单里出现该条 |
| 文件 | 把 hello 写到 a.txt → 读一下 a.txt |
hello |
| 记忆 | 我叫刘浩彬 → 我叫什么 |
你叫刘浩彬。 |
| MCP | --mcp "现在几点" |
正常返回,动作记录显示 current_time |
| 网页 | POST /api/chat {"message":"(2+3)*4"} |
{"reply":"20","actions":[calculator ✓]} |
4.4 性能数据
在本机(Windows 10 / Python 3.8)实测:
| 项目 | 耗时 |
|---|---|
| 本地工具调用(计算器) | 约 4.8 微秒/次 |
| MCP 往返(管道 + JSON) | 约 0.03 毫秒/次 |
| 51 个单元测试 | 约 0.6 秒 |
| 一条命令端到端(含解释器启动) | 约 0.3~0.4 秒 |
结论:工具和协议本身几乎没有开销,真正慢的是大模型调用(几百毫秒到几秒)。
所以如果要优化,方向应该是减少模型往返次数(比如一次规划多个动作),而不是抠工具执行。
4.5 测试的局限
- 大模型模式没有做真实接口测试(没有 Key、CI 没有外网),只验证了请求构造和错误处理;
- 离线规划器只覆盖固定说法,换个说法就会落到"还不太会";
- 天气工具依赖第三方 API,接口变更时测试里的 mock 不会发现,需要定期手工验证。
五、使用示例
5.1 命令行(离线模式,零配置)
> python agent.py "帮我算一下 (3+5)*2"
16
> python agent.py "记一下 周五前交周报"
已记下第 1 条待办: 周五前交周报
> python agent.py "看看待办"
待办清单:
1. [ ] 周五前交周报
> python agent.py "完成第 1 条"
已完成第 1 条: 周五前交周报
> python agent.py "把 会议改到周三 写到 notes/todo.md"
已写入 notes/todo.md (24 字节)
> python agent.py "读一下 notes/todo.md"
会议改到周三
> python agent.py "现在几点"
2026-10-21 10:24:31 (星期三)
> python agent.py "我叫刘浩彬"
记住了, 你叫刘浩彬。
> python agent.py "我叫什么"
你叫刘浩彬。
5.2 走 MCP
> python agent.py --mcp "现在几点"
2026-10-21 10:24:32 (星期三)
加 --show-actions 可以看到每一步调用了什么:
[工具] current_time({}) -> ok
5.3 网页界面
python web_ui.py --port 8000
打开 http://127.0.0.1:8000,输入框里说一句话,
气泡下面会显示"工具: calculator ✓"。
5.4 大模型模式
$env:AGENT_API_KEY="sk-xxx"
$env:AGENT_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
$env:AGENT_MODEL="qwen-plus"
python agent.py
这时可以随便说,比如:
帮我看一下北京天气,然后记一下明天带伞
模型会自己拆成两次工具调用(weather + todo_add),再合成一句回答。
六、加分项对照
| 加分项 | 是否实现 | 具体做法 |
|---|---|---|
| 多轮对话记忆 | ✅ | 最近 20 轮历史 + 长期事实落盘;支持"我叫什么""那上海呢" |
| 使用语音输入/输出 | ❌ | 没做。需要麦克风权限和 TTS 依赖,这次时间不够,列进了后续计划 |
| 与外部 API 或数据库交互 | ✅ | 天气走 Open-Meteo 公开 API;待办、记忆落盘为 JSON |
| 简洁的前端界面 | ✅ | web_ui.py + 单页聊天界面,展示每次工具调用 |
七、小组分工
| 成员 | 学号 | 负责内容 |
|---|---|---|
| 刘浩彬 | 3124004477 | 架构设计,agent_core / planner / tools / MCP 的实现,测试与文档 |
| 邓家俊 | 3124004465 | 需求与业务流梳理,工具清单与测试用例设计,异常提示文案与界面走查 |
| 曲浩宾 | 3124004483 | 离线模式的说法收集与规则设计,天气接口调研,文档校对 |
| 李忠林 | 3124004474 | 测试用例执行与缺陷记录,网页界面验收,演示脚本与录屏 |
八、成员心得
刘浩彬:这次最大的收获是"工具调用的边界在哪里"。一开始我以为只要把工具交给模型,
它自己会用;实际写下来才发现,模型负责决定"做什么",代码负责守住"不能做什么"——
路径不能出 workspace、表达式不能带函数调用、指数不能太大,这些必须在工具层做死,
不能指望提示词。
第二个体会是"离线模式"的价值。它一开始只是为了演示方便,但写着写着发现,
把每个工具用一句话描述清楚、想明白用户会怎么说,其实就是在做需求分析。
等再回头写系统提示词时,这些规则直接就能用。
还有一个坑:MCP 服务一开始用 sys.stdin.readline() 读,中文参数在 Windows 控制台下
会被编码弄坏;改成 sys.stdin.buffer 按 UTF-8 字节读写才稳定。
这种"协议本身没问题、编码有问题"的 bug,只有真跑一遍才会遇到。
邓家俊:我主要做需求梳理和测试走查。最大的感受是"能说"和"会做"之间最容易出问题的是参数:
用户说"读一下那个文件",模型可能给出一个不存在的路径,或者忘了给文件名。
所以工具遇到参数不对时必须说清楚哪里不对,不能只回一个"失败"。
另一个印象深的是走查网页界面:把每次调用的工具显示出来之后,整个系统一下子"透明"了。
用户能看见它到底做了什么、有没有失败,信任感完全不一样。这比回答得多漂亮更重要。
曲浩宾:我负责收集离线模式的说法。一开始以为"记一下"就是一句话,
实际收集下来,同一个意思能说出十几种:"记一下""帮我记一下""加个待办""提醒我"……
最后整理成一张"说法 → 工具"的清单,才明白规则版为什么必须限定说法。
也是因为做过这张表,再看大模型的 function calling 时一下就理解了:
它做的其实就是"把说法映射到工具"这件事,只是它不需要我们把每一种说法都写出来。
另外整理天气工具时发现,城市名前面经常带"帮我查一下""今天"这些字,
不清理就会把"北京的"当成城市名——这种细节只有把真实说法列出来才会暴露。
李忠林:我主要做测试执行和演示准备。印象最深的是演示彩排那次:现场没有网,
天气工具直接用不了,我们临时改成演示算数、待办和文件——幸好有离线模式,
整条流程还能完整走下来,不然那天就尴尬了。
执行测试时还发现,出问题最多的不是"算错",而是参数:文件路径不对、序号越界、
表达式里带了不该有的符号。工具把这些错误说清楚之后,体验差别很大;
如果只回一个"失败",用户根本不知道下一步该改什么。
做录屏的时候也有个体会:演示要挑"结果看得见"的操作。
写文件、记待办、读文件这一串操作,屏幕上能直接看到文件多出来、内容对得上,
比单纯问"现在几点"有说服力得多。
九、踩坑记录
| 问题 | 原因 | 解决 |
|---|---|---|
| 中文参数传给 MCP 服务后变乱码 | Windows 控制台默认编码不是 UTF-8 | 服务端和客户端都改用 stdin.buffer / stdout.buffer 按 UTF-8 字节读写 |
| "帮我查一下北京的天气"识别成"北京的" | 正则里的字符组是贪婪匹配,把"的"也吃掉了 | 改成非贪婪,再补一层前后缀清理 |
| "那上海呢"识别成"上海呢" | 追问正则同样贪婪 | 把城市名的量词改成非贪婪 |
| 记忆文件写失败导致整次对话崩 | 没有隔离存储失败 | 捕获 OSError,只跳过保存 |
| 模型喜欢心算大数,算错 | 提示词没有约束 | 系统提示里写死"能算的用 calculator,不要假装算过" |
| 模型可能反复调工具 | 没有上限 | 最多 6 轮,超过就停 |
十、后续计划
- 语音输入/输出:用系统 TTS(Windows SAPI)和语音识别补上"能说"的另一种形态;
- 工具拆分:把文件服务、待办服务拆成独立的 MCP 服务,演示多服务协作;
- 一次规划多个动作:现在模型一次可能只调一个工具,可以让它先输出完整计划再执行,
减少往返次数; - 更长的记忆:现在是最近 20 轮 + 少量事实,后续可以按主题摘要压缩历史。

浙公网安备 33010602011771号