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

这个作业属于哪个课程 软件工程
这个作业要求在哪里 小组作业:智能体
这个作业的目标 用现代 AI 工具构建一个既"能说"(自然语言对话)又"会做"(调用工具真正执行)的智能体
学号 3124004477(刘浩彬)、3124004465(邓家俊)、3124004483(曲浩宾)、3124004474(李忠林)

办公小助手:一个"能说会做"的智能体

代码地址:https://github.com/Siruetto/liuhaobin (本次作业在 AgentProject/ 目录下)

一句话介绍:用一句自然语言指挥它干活——算数、记待办、读写文件、查天气、看时间。
它不是"告诉你怎么做",而是真的去做,做完再用人话汇报。

项目规模:9 个工具、10 个 Python 模块、51 个单元测试、1 个 MCP 服务、1 个网页界面,
不依赖任何第三方库。

一、需求描述

1.1 要解决的麻烦

日常办公的琐事散落在很多地方:算个数要开计算器,记事情要开备忘录,改个文件要开编辑器,
查天气要开浏览器,看时间要掏手机。每件事都很小,但来回切换很烦,而且经常忘。

我们想要的是:一个入口,一句话,剩下的它自己办。

1.2 目标与底线

  1. 能说:支持多轮对话,记得住上下文。比如先问"北京天气",再问"那上海呢",
    它要知道你在问天气。
  2. 会做:每个回答背后要么有一次真实的工具调用,要么明确说"这个我还不会"。
    宁可承认不会,也不许编。
  3. 可扩展:加一个新工具只改一个注册表,规划器、MCP、网页、测试都不用动。
  4. 能离线演示:没配 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":

  1. 规划器照样提取出 path=../secret.txt;
  2. file_write 在解析路径时发现越界,抛 ToolError("只能操作 workspace/ 目录里的文件");
  3. 规划器捕获它,把这句话变成回复;
  4. 用户看到"工具出错: 只能操作 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。它不是要替代模型,而是保证离线也能把链路跑完整。

设计上有三点:

  1. 规则按"动词 + 宾语"组织:记一下 X → todo_add,几点了 → current_time,
    把 X 写到 Y → file_write;
  2. 顺序敏感:先匹配更具体的规则(比如"完成第 2 条"要先于"待办"),
    否则会被宽泛规则抢走;
  3. 保留上下文:记住上一次的意图,所以"北京天气"之后说"那上海呢"也能接上;
    另外支持"我叫 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"))

为什么用子进程:

  1. 符合 MCP 的设计——工具服务可以独立部署、独立重启;
  2. 有隔离,工具把进程搞挂了,主程序也只是收到"服务没有响应";
  3. 同一个服务可以被别的 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 轮,超过就停

十、后续计划

  1. 语音输入/输出:用系统 TTS(Windows SAPI)和语音识别补上"能说"的另一种形态;
  2. 工具拆分:把文件服务、待办服务拆成独立的 MCP 服务,演示多服务协作;
  3. 一次规划多个动作:现在模型一次可能只调一个工具,可以让它先输出完整计划再执行,
    减少往返次数;
  4. 更长的记忆:现在是最近 20 轮 + 少量事实,后续可以按主题摘要压缩历史。
posted @ 2026-10-21 18:43  刘浩彬  阅读(12)  评论(0)    收藏  举报