博客园随笔-Milk-Dragon-MCP智能体
用 MCP 搭一个会说也会做的聊天智能体
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 构建一个能说会做的智能体 - 作业 - 计科24级78班 - 班级博客 - 博客园 |
| 这个作业的目标 | 使用现代 AI 工具构建一个同时具备语言交互(会说)与行动执行(会做)能力的智能体:用自然语言接收任务、理解意图,并调用工具完成操作,而不是只把大模型包一层聊天外壳。采用 MCP,把「决策」和「执行」都落在我们自己写的代码里,为大项目打基础。 |
| 学号 | 3124004430 林炳辉 , 3124004426 黄国轩 ,3124004443 吴俊达 ,3124004444 谢嘉鸿 , 3124004453 张志远 |
写在前面
这次作业要求做一个「能说会做」的智能体。听起来简单,真正动手才发现有个躲不开的矛盾:
要「会说」,最省事的做法是接个大模型;要「会做」,必须有工具。但演示现场不一定有 API Key、也不一定有网络。
很多同学的做法是:接一个大模型 API,写一段 while True: 用户输入 → 模型 → 打印回复。这东西严格来说只是「加了对话外壳的聊天框」——智能的部分全在模型里,我们自己的代码没有承担任何决策。
我们想做点不一样的:智能体的「决策」和「执行」都跑在我们自己写的代码里,大模型是可选的增强插件,不是必需品。最终结果是这样:
你 ❯ 帮我查上海未来三天天气,然后画成柱状图
Milk Dragon ❯ 收到收到,龙龙马上办!用「查天气」来做这一步。
▶ 做:get_weather(days=3, unit='c', city='上海')
✓ 得:上海 现在 18℃,晴 ·mcp
· 想:本龙先去看看~用「生成图表」来做这一步。
▶ 做:make_chart(kind='bar', title='上海气温趋势',
labels=['10-21','10-22','10-23'], values=[21,21,22])
✓ 得:画好了柱状图「上海气温趋势」,共 3 组数据:chart_xxx.png ·mcp
Milk Dragon ❯ 1. 上海 现在 18℃,晴
体感 15℃,湿度 64%,风速 18 km/h
2. 画好了柱状图「上海气温趋势」,共 3 组数据
注意 ·mcp 这个标记——它表示这次工具调用真的穿过了一个 MCP 协议的 stdio 子进程,不是本地函数调用。这是我们整篇最想讲清楚的部分。
它叫 Milk Dragon(奶龙),一只憨萌的小恐龙:你跟它聊天,它替你干活——算数学、查天气、画图表、做梗图、记待办、读写文件、帮你决定今天吃什么。
一、需求描述
1.1 一句话定位
Milk Dragon 是一个中文聊天智能体:你用自然语言使唤它,它自己判断该做什么、真的去做,然后把结果讲给你听。
1.2 三个用户故事
作业里举的例子是「整理待办」「查天气出行计划」「AI 办公助理」。我们各取一部分,拼成了一个更贴近学生日常的场景:
| 用户故事 | 用户说什么 | 智能体要做什么 |
|---|---|---|
| 算数 | 「算一下 (12+8)*3/5 保留两位小数」 | 解析算式、安全求值、按精度输出 |
| 查天气并可视化 | 「帮我查上海未来三天天气,然后画成柱状图」 | 两步:先查天气,再用查到的数据画图 |
| 记住事情 | 「记一下:周四交作业」 | 写进持久化待办,能查、能标记完成 |
第三个场景对应作业里的「整理任务和生成待办」,第二个对应「查询天气并生成出行计划」——但我们把它做成了真正的多步:第二步的输入来自第一步的真实返回值。
1.3 功能清单
八个工具,覆盖「计算结果 / 调用 API / 生成图片 / 处理文件」四类能力:
| 工具 | 能力 | 依赖 |
|---|---|---|
calculate |
数学表达式求值,AST 白名单,不用 eval | 标准库 |
get_weather |
查天气,三级降级:wttr.in → open-meteo → 本地示例 | 标准库 |
make_chart |
柱状图 / 折线图 / 饼图,中文不乱码 | matplotlib |
make_meme |
梗图 / 便签 / 海报 / 恐龙海报,含原创像素小恐龙 | Pillow |
make_ascii_art |
字符画,纯标准库点阵,兜底方案 | 标准库 |
todo |
待办增删改查,JSON 持久化 | 标准库 |
files |
沙箱内文本文件读写 | 标准库 |
fun_pick |
掷骰子 / 抛硬币 / 今天吃什么 / 转盘 | 标准库 |
只有两个工具需要第三方库,所以即使 pip install 装包失败,仍有六个工具可用——演示不会开天窗。
1.4 非功能要求
智能体「能动手」就意味着它有破坏力,所以我们给自己加了四条约束:
| 要求 | 我们的做法 |
|---|---|
| 无 Key 可跑 | 内置规则式规划器,完全不依赖大模型 |
| 无网可跑 | 天气三级降级,最终落到本地示例数据并明确标注,不假装是真的 |
| Windows 可用 | 路径 / 编码 / 事件循环三个坑都单独处理过 |
| 安全 | 计算器禁 eval;文件操作走白名单沙箱 |
| 可解释 | 每个决策都能打印出「这一分是怎么来的」 |
二、业务流描述
2.1 整体架构
┌──────────────┐ ┌──────────────┐
│ CLI 终端 │ │ Streamlit 网页│ 界面层
└──────┬───────┘ └──────┬───────┘
└────────┬─────────┘
▼
┌───────────────┐
│ Agent 主循环 │ Thought → Action → Observation → Final
└───┬───────┬───┘
│ │
┌─────▼──┐ ┌──▼──────────┐
│ 规划器 │ │ 工具通道 │
└────┬───┘ └──┬──────────┘
│ │
┌───────▼──┐ ┌──▼────────────────┐
│ Mock 规则 │ │ MCP Client (stdio)│
│ LLM 兼容 │ └──┬────────────────┘
└──────────┘ │ JSON-RPC
┌──▼────────────────┐
│ MCP Server 子进程 │
└──┬────────────────┘
│ 同一份工具实现
┌──▼────────────────┐
│ 8 个工具 → data/ │
└───────────────────┘
关键约束:agent/ 不 import mcp,也不 import streamlit。内核、界面、协议三层解耦,所以命令行、网页、单元测试三方复用同一个大脑。
2.2 一次多步请求的时序
「查上海天气然后画柱状图」为什么值得单独讲?因为很多作业的「多步」是假的——规划器一次生成两个工具调用,看起来走了两步,但第二步的参数在规划那一刻就定死了,那时第一步还没执行,数据根本不存在。
我们的做法是每一步只做一个动作:
用户:帮我查上海未来三天天气,然后画成柱状图
│
├─ 归一化 → "查上海未来3天天气,然后画成柱状图"
├─ 切分 → ["查上海未来3天天气", "画成柱状图"]
│
├─ 第1轮 plan → ToolCall(get_weather, {city:上海, days:3})
│ └─ 执行 → Observation: {series:{date:[...], temp_max:[...]}}
│
├─ 第2轮 plan(此时 observations 里已有上一步结果)
│ ├─ 打分:make_chart 因为「上一步是天气」拿到 +2.0 上下文加分
│ ├─ 槽位:labels/values 直接从 observations 里取
│ └─ ToolCall(make_chart, {labels:[...], values:[...], kind:bar})
│ └─ 执行 → Observation: PNG 路径
│
└─ 第3轮 plan → 片段用完了 → final_text 总结两步结果
第二步的数据真的来自第一步的返回值,这是「真 ReAct」和「假动画」的分界线。
2.3 两条降级链
智能体最怕的不是出错,是出错后整场演示垮掉。所以我们给最不稳定的两处都做了降级:
天气 :wttr.in ──失败──▶ open-meteo ──失败──▶ 本地示例数据(标注「离线示例」)
工具通道:MCP 子进程 ──握手超时──▶ 进程内直调(界面显示 via=builtin)
降级时界面会如实显示切换原因,不假装一切正常——这点我们觉得比「永远成功」更重要。
2.4 记忆与多轮
短期记忆(deque 最近 12 条)──┐
待补槽位 pending ─────────────┼──▶ PlanContext ──▶ 规划器
长期偏好 data/state/memory.json ─┘
说「天气怎么样」缺城市,它会先问;下一轮你说「北京」,它从 pending 里把任务接回来继续做。
三、实现说明
3.0 技术栈
| 项 | 选择 |
|---|---|
| 语言 / 版本 | Python 3.10+(实测 3.13 通过) |
| 协议框架 | MCP(Model Context Protocol),官方 Python SDK mcp==2.3.0 |
| 传输 | stdio 子进程 + JSON-RPC |
| 界面 | 命令行(agent.ui.cli)+ Streamlit 网页(agent.ui.web_app) |
| 生图 | Pillow(梗图/海报)、matplotlib(图表) |
| 规划器 | 规则式 MockPlanner / LLM function calling LLMPlanner(同一接口) |
| 测试 | pytest,203 passed / 1 skipped |
3.1 第一课:MCP 的文档和 pip 不是一回事
这是我们踩得最深的一个坑,也最值得写下来。
网上教程里(包括很多中文博客)的 MCP Python SDK 写法长这样:
# v1.x 的写法
from mcp.server.fastmcp import FastMCP
from mcp.client.stdio import stdio_client
from mcp.client.session import ClientSession
mcp = FastMCP("demo")
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
照着写完,pip install mcp 之后运行,直接:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
因为现在 pip install mcp 装的是 2.3.0,v2 做了破坏性改名:
| 能力 | v1.x | v2.x |
|---|---|---|
| 服务端高层类 | mcp.server.fastmcp.FastMCP |
mcp.server.MCPServer(旧路径已删除,不是弃用) |
| 客户端 | stdio_client + ClientSession + await initialize() |
一个 Client(StdioServerParameters(...)) |
| 错误类型 | McpError |
MCPError |
| 字段命名 | tool.inputSchema、result.isError |
tool.input_schema、result.is_error(线上仍是 camelCase) |
| 传输配置 | FastMCP("x", port=9000) |
全部移到 run(),构造函数传会 TypeError |
所以我们的 requirements.txt 里写的是精确版本:
mcp==2.3.0
并在 scripts/check_env.py 第一屏就打印 SDK 版本——让环境问题在跑之前暴露,而不是跑的时候。
3.2 第二课:低层 Server 走不了 stdio
我们本来想用低层的 Server,因为它的 on_list_tools / on_call_tool 能直接把工具注册表对接上去,零反射、零重复。但读了官方文档才发现此路不通:
mcp devandmcp runonly accept anMCPServer, so you serve this one yourself.
There is noserver.run(transport=...)down here.
低层 Server 的 run() 签名是 run(read_stream, write_stream, init_options),得自己构造 stdio 的读写流。为了一个学期项目,不值得。最终方案是:高层 MCPServer + run()(默认就是 stdio)。
# mcp_server/server.py
from mcp.server import MCPServer
from mcp_server import wrappers
def build_server() -> MCPServer:
server = MCPServer("milk-dragon-agent", version="0.1.0", instructions=INSTRUCTIONS)
registry = get_registry()
for function in wrappers.ALL:
spec = registry.get(function.__name__)
# 显式传 title:否则 MCP 侧只会暴露函数名,界面上会显示 make_meme
# 而不是「生成梗图」,与内置通道的观感不一致
server.tool(title=spec.title)(function) if spec else server.tool()(function)
return server
server = build_server() # mcp run 会自动寻找名为 server / mcp / app 的对象
3.3 怎么做到「工具只写一遍」
这是整个项目的骨架。矛盾在于:MCP 那边要 JSON Schema,本地调用那边要 Python 函数,很容易写成两份然后不同步。
解法是一份 SPEC 作为唯一真源:
# agent/tools/calc.py
SPEC = ToolSpec(
name="calculate",
title="计算器",
description="计算数学表达式并返回数值结果。用户提出任何算术问题时使用。",
input_schema={
"type": "object",
"properties": {
"expression": {"type": "string"},
"precision": {"type": "integer", "minimum": 0, "maximum": 12, "default": 4},
},
"required": ["expression"],
},
tags=["calc"],
)
def run(args: dict) -> ToolResult: ... # 唯一实现,纯同步函数
于是三个消费方各取所需:
- 本地调用:
registry.invoke(name, args)→run(args) - MCP 暴露:
mcp_server/wrappers.py只写类型适配的薄壳 - 喂给大模型:
ToolSpec.input_schema直接就是 OpenAI 的function.parameters
MCP 侧的薄壳长这样(注意它不 import mcp):
# mcp_server/wrappers.py
def _call(tool_name: str, arguments: dict) -> str:
result = get_registry().invoke(tool_name, arguments)
result.via = "mcp" # 由服务端盖章,客户端据此证明「真的走了 MCP」
return result.to_json()
def calculate(expression: str, precision: int = 4) -> str:
return _call("calculate", {"expression": expression, "precision": precision})
不 import mcp 有两个好处:没装 mcp 包时一致性测试仍能跑;依赖方向保持单向(mcp_server → agent,绝不反过来)。
那怎么保证两处不写歪?用测试,不用技巧。
# tests/test_schema_parity.py
def test_服务端_tools_list_与注册表一致(specs):
async with mcp.Client(server) as client:
listed = await client.list_tools()
for name, spec in specs.items():
tool = {t.name: t for t in listed.tools}[name]
assert set(tool.input_schema["properties"]) == set(spec.properties())
for param, prop in spec.properties().items():
assert _schema_type(tool.input_schema["properties"][param]) == prop["type"]
这段测试会真正把服务端跑起来拉一次 tools/list,逐字段比对。谁改歪了立刻红。
我们最初想让 SDK 通过伪造
__signature__反推 schema,后来放弃了:MCPServer是从类型标注生成 schema 的,伪造签名未必生效,而且失败了会静默出错。用测试钉死,比用反射取巧可靠。
3.4 安全:两处必须自己写
3.4.1 计算器:不用 eval
eval("__import__('os').system('rm -rf /')") 一行就能拿到系统权限,加正则黑名单也能被各种编码绕过。所以改成自己解释 AST:
def _eval_node(node, depth):
if isinstance(node, ast.Constant):
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
raise _UnsafeExpression("只允许数字常量")
return node.value
if isinstance(node, ast.BinOp):
... # 只认 + - * / // % **
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _ALLOWED_FUNCS:
raise _UnsafeExpression("只允许白名单数学函数")
raise _UnsafeExpression(f"不支持的语法:{type(node).__name__}")
只认数字常量、四则运算、幂、取负,和白名单里的 sqrt/log/exp/...。遇到 Call、Attribute、Subscript、Lambda、Comprehension 一律拒绝。
还顺手堵了资源攻击:2**999999999 字符不多,但会让 CPython 算到天荒地老。我们限制指数 ≤ 64、底数 ≤ 1e6、表达式长度 ≤ 200,并在结果上做量级检查。
单测里 18 条恶意表达式全部被拦,包括:
__import__('os').system(...) 1 < 2
().__class__.__bases__[0]... 'abc' * 3
open('C:/Windows/win.ini').read() lambda: 1
3.4.2 文件:白名单沙箱
files 工具只能碰 data/files/ 下的文件。不是「尽量拦」,而是逐段校验后再 resolve 一次做兜底:
resolved = candidate.resolve()
try:
resolved.relative_to(base_resolved) # 兜底:防符号链接绕过
except ValueError:
raise SandboxViolation("路径越界")
拦的东西包括:绝对路径、盘符、UNC、..、Windows 保留设备名(CON/NUL/COM1…)、非法字符、首尾空格、结尾点、超长文件名。测试里 35 个用例覆盖六类绕过手法。
顺便说个真实现象:Windows 上无开发者权限时
Path.symlink_to()可能既不报错也没真的建立链接,我们的测试因此加了if not link.is_symlink(): pytest.skip(...),否则会得到一个假阳性。
3.5 没有大模型,它凭什么听得懂?
这是我们认为最有价值的部分。核心问题是:不接 LLM 的规则式规划器,怎么才能不像玩具?
答案是把「听懂」拆成六段流水线,每段都能单独测:
归一化 → 分句 → 意图打分 → 槽位抽取 → 决策 → schema 校验
3.5.1 归一化:别小看这一步
真实输入充满噪音:
| 原始输入 | 归一化后 |
|---|---|
帮我查一下上海未来三天天气! |
查一下上海未来3天天气 |
(1+2)×3 |
(1+2)*3 |
算一下 (12+8)*3/5 保留两位小数 |
算一下 (12+8)*3/5 保留2位小数 |
中文数字只在后接量词时才转换,否则「算一下」会被改成「算1下」:
_CN_NUM_RE = re.compile(r"[零〇一二两三四五六七八九十百千]+(?=[天个次位张元分秒点号月年人杯碗遍公])")
3.5.2 意图打分:五路加权,每分都能解释
不是关键词 if-else,而是带权打分:
score = Σ trigger_w × min(hits, 2) × pos_boost(1.0~1.3)
+ Σ object_w × min(hits, 2)
+ Σ negative_w
+ ctx_bonus # 上一步调了天气,这一步说「画图」→ +2.0
+ diversity_bonus # 命中来源 ≥2 类 → +0.6
几个设计细节:
- 位置权重:「天气」出现在句首通常是主题,出现在句尾可能是补充,所以越靠前 bonus 越大(最早命中 1.3,最晚 1.0)
- 负分:「天气之子好看吗」不该触发查天气,所以
("天气之子", -8.0) - 上下文延续:
make_chart声明followups={"get_weather"},上一步查了天气就给 +2.0——这是「然后画成柱状图」能命中的关键 - 三档阈值:
≥2.6执行;1.2~2.6反问澄清(列候选让用户选);<1.2走闲聊
调试时可以直接看到每一分怎么来的:
$ python -m agent.planner.mock_planner "帮我查上海未来三天天气,然后画成柱状图"
输入 : 帮我查上海未来三天天气,然后画成柱状图
归一化后 : 查上海未来3天天气,然后画成柱状图
切分片段 : ['查上海未来3天天气', '画成柱状图']
打分明细 :
[查上海未来3天天气]
get_weather: 5.23 (trigger=+3.63, object=+1.00, diversity=+0.60)
make_ascii_art: 0.80 (object=+0.80)
槽位 : {"days": 3, "unit": "c", "city": "上海"}
动作 : get_weather({"days": 3, "unit": "c", "city": "上海"})
3.5.3 准确率靠测试守,不靠感觉
tests/test_planner_intents.py 里有一张 51 条真实说法的表(含口语、省略、负例),并把它作为硬门槛:
MIN_INTENT_ACCURACY = 0.90 # 工具选择准确率
MIN_SLOT_ACCURACY = 0.85 # 参数抽取准确率
目前这 51 条全部命中(100%),参数抽取也达标。有人把词表改坏了,测试立刻报错。
覆盖的负例包括:「天气之子好看吗」「画个饼」「随便聊聊吧」「今天几号」——这些都不该调工具。
3.5.4 多轮对话:缺什么问什么
说「天气怎么样」,缺城市:
Milk Dragon ❯ 你想查哪个城市的天气呀?
你 ❯ 北京
Milk Dragon ❯ 北京 现在 15℃,烟霾 …
实现上是把待补的槽位存进 memory.pending,下一轮先尝试用它续接:
if ctx.pending and not ctx.observations:
resumed = self._resume_pending(ctx, text, debug)
3.5.5 失败自纠
工具报错不是终点,而是下一轮规划的依据:
if ctx.observations and not ctx.observations[-1].ok:
repaired = self._self_repair(ctx, segments, debug) # 比如去掉非法日期重试
if repaired is None:
return PlanDecision(ask_user=persona.apologize(error, hint))
比如用户说「记一下:交作业 明天」,日期解析失败 → 去掉 due 重试一次 → 成功。
3.6 让两种规划器共用一个主循环
这是「换模型零改内核」的结构保证,不是口号:
class Planner(ABC):
def plan(self, ctx: PlanContext) -> PlanDecision: ...
MockPlanner 和 LLMPlanner 返回同一种 PlanDecision:
@dataclass
class PlanDecision:
thought: str # 一定会打印,制造「思考过程」
calls: list[ToolCall] = ... # 要执行的动作
final_text: str | None = None # 非空 → 本轮结束
ask_user: str | None = None # 非空 → 反问
debug: dict = ... # 打分明细
所以 Agent.chat() 里只有一条执行路径。有 Key 就换一行构造:
planner = MockPlanner() # 没 Key
planner = LLMPlanner(settings) # 有 Key,主循环一行都不用改
而且 LLMPlanner 在 Key 错 / 网络挂时自动退回规则式规划器,并在思考里标注——最差情况也能演示。支持任何 OpenAI 兼容服务(DeepSeek、智谱 GLM、阿里百炼、本地 vLLM)。
3.7 Windows 上的两个必踩坑
3.7.1 事件循环:一个类同时解决两个问题
- 坑一:Windows 默认的 Selector 事件循环无法
create_subprocess_exec,而 MCP 客户端正是用它起子进程 - 坑二:Streamlit 里
asyncio.run()会抛cannot be called from a running event loop
解法是自建一个常驻运行时:
class AsyncRuntime:
def _serve(self):
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
loop = asyncio.new_event_loop()
loop.run_forever()
def submit(self, coro, timeout=None):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
后台线程 + 常驻 Proactor loop,全项目只用 runtime.submit(),不出现裸 asyncio.run()。
3.7.2 中文:字体、编码、stdout
- 字体:matplotlib 默认没有汉字,标题会变成方块。我们不只检查文件是否存在,而是真的渲染一个「测」字看有没有像素,因为字体文件可能存在但缺少 CJK 字形
- 编码:子进程必须带
-X utf8+PYTHONUTF8=1,所有文件读写显式encoding="utf-8" - stdout 是协议通道:MCP 服务端里一次
print()就能让整个 JSON-RPC 帧损坏。所以服务端禁用print,日志全部logging到 stderr 与data/state/mcp_server.log
3.8 还有两个只在真实运行时才会发现的细节
这两个不在任何文档里,是跑出来撞上的:
ListToolsResult是 pydantic 模型,直接for tool in listed会得到(字段名, 值)元组,报'tuple' object has no attribute 'name'。必须写listed.tools- 服务端返回
dict时,客户端的structured_content是None;只有返回str才有{"result": ...}。所以我们的工具统一返回 JSON 字符串,客户端从content[0].text解析——两种通道的结果结构因此完全一致,主循环一行分支都不用写
四、使用示例(功能演示)
4.1 先跑环境自检
$ cd 3124004444
$ python scripts/check_env.py
=== 依赖包 ===
[ OK ] mcp (MCP SDK) 2.3.0
[ OK ] Pillow 11.1.0
[ OK ] matplotlib 3.10.1
=== 中文字体 ===
[ OK ] 可用 C:\WINDOWS\Fonts\msyh.ttc
=== MCP 协议 ===
[ OK ] 协议版本 2026-07-28
[ OK ] 握手成功 暴露 8 个工具
4.2 八组演示(python -m agent.ui.cli --demo)
| # | 输入 | 实际输出 |
|---|---|---|
| 1 | 算一下 (12+8)*3/5 保留两位小数 |
(12+8)*3/5 = 12 |
| 2 | 北京今天天气怎么样 |
北京 现在 18℃,阴 / 体感 17℃,湿度 54%,风速 19 km/h |
| 3 | 帮我查上海未来三天天气,然后画成柱状图 |
两步:get_weather → make_chart(数据来自第一步) |
| 4 | 画个梗图:周五了,代码还没写完 |
图做好啦(大字梗图):meme_20261021_193905.png |
| 5 | 记一下:周四交作业 |
记下啦:#4 周四交作业,截止 2026-10-22 |
| 6 | 列出待办 |
共 4 条待办,4 条未完成:… |
| 7 | 今天吃什么,帮我选一个 |
本龙替你决定了:今天吃「铁板炒饭」! |
| 8 | 把 HI 转成字符画 |
标准库点阵输出 H I |
第 7 条的实际工具调用是 fun_pick(mode='lunch', sides=6, count=1),第 8 条是 make_ascii_art(scale=1, text='hi', fill='#')——注意 text 被归一化为小写,这就是归一化流水线在起作用。
4.3 生成的图片
多步任务的产物(柱状图的数据来自上一步查到的真实天气):

梗图:

恐龙海报(小恐龙是用几何图形逐块画的原创图形,不含任何第三方卡通素材):

其他图表样式:


4.4 一次真实的多轮对话
你 ❯ 天气怎么样
· 想:还缺参数 city,先问用户。
Milk Dragon ❯ 你想查哪个城市的天气呀?
你 ❯ 北京
▶ 做:get_weather(days=1, unit='c', city='北京')
✓ 得:北京 现在 15℃,烟霾 ·mcp
Milk Dragon ❯ 北京 现在 15℃,烟霾
体感 10℃,湿度 16%,风速 11 km/h
4.5 怎么证明「真的走了 MCP」
三种方式,都可以在答辩现场当场演示:
# ① 终端上每次工具调用后面都会带 ·mcp 标记
python -m agent.ui.cli --channel mcp
# ② 看 trace:channel 字段会写清楚当前用的是哪个通道
# 交互模式输入 /trace
# ③ Windows 上看子进程是否真的起来了
tasklist | findstr python
4.6 断网时会发生什么
$ python -m agent.ui.cli --no-network -c "杭州天气"
Milk Dragon ❯ 杭州 现在 27℃,小雨
体感 26℃,湿度 69%,风速 24 km/h
(注意:这是离线示例数据,不是实时天气哦)
它不会假装数据是真的——这条「如实标注」是我们特意做的。
4.7 网页端
$ pip install -r requirements-web.txt
$ streamlit run agent/ui/web_app.py
左侧栏实时展示动态发现的工具清单(走 MCP 时是真从服务端拉的),每次工具调用用可折叠块展示「想 → 做 → 得」,图片直接显示在对话里。
4.8 想接大模型
默认不需要任何 API Key 就能完整运行。想用真大模型:
cp .env.example .env
# 编辑 .env:AGENT_MODE=llm,填 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
支持任何 OpenAI 兼容服务,代码一行都不用改。Key 填错或网络不通时会自动退回规则式规划器,不会崩。
五、GitHub 链接
仓库地址:https://github.com/gigittty/mcp-chat-agent
5.1 目录结构
说明文档在仓库根目录,所有代码在 3124004444/ 子目录。
mcp-chat-agent/
├── README.md ← Agent 说明文档(入口)
├── docs/
│ ├── blog.md ← 本文的仓库内版本
│ ├── architecture.md ← 架构图与流程图(Mermaid)
│ ├── demo_script.md ← 答辩演示脚本 + 可能被问到的问题
│ ├── demo_transcript.md ← 8 组演示的真实执行记录(自动生成)
│ ├── 小组分工.md
│ ├── 成员心得.md
│ ├── 学习资源.md
│ └── images/ ← 演示图片
└── 3124004444/ ← 全部可运行代码(约 6300 行)
├── agent/ ← 智能体内核(不依赖 UI、不依赖 mcp)
│ ├── planner/ ← 六段流水线:归一化/分句/打分/槽位/校验
│ ├── mcp_host/ ← MCP 客户端与通道降级
│ ├── llm/ ← OpenAI 兼容 function calling
│ ├── tools/ ← 8 个工具实现
│ └── ui/ ← CLI + Streamlit
├── mcp_server/ ← 把工具以 MCP 协议暴露出去
├── tests/ ← 203 项测试
└── scripts/ ← 环境自检、演示脚本
5.2 三步跑起来
cd 3124004444
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
python scripts/check_env.py
python -m agent.ui.cli
5.3 三种运行姿势
python -m agent.ui.cli # 默认:自动选通道
python -m agent.ui.cli --channel mcp # 强制 MCP(证明真的在用协议)
python -m agent.ui.cli --channel builtin # 不装 mcp 也能跑
streamlit run agent/ui/web_app.py # 网页端
5.4 测试
$ pytest -q
203 passed, 1 skipped
| 测试文件 | 覆盖内容 |
|---|---|
test_calc_safety.py |
18 条恶意表达式必须被拒绝 |
test_sandbox.py |
35 条路径绕过用例(..、盘符、UNC、CON…) |
test_tools_contract.py |
八个工具的 schema 合法性、空参数不崩 |
test_planner_intents.py |
51 条中文说法,意图 ≥90% / 参数 ≥85% 门槛 |
test_schema_parity.py |
MCP tools/list 与注册表逐字段一致 |
test_mcp_smoke.py |
真起子进程走 stdio 完成 list/call |
test_agent_loop.py |
多步、错误自纠、步数上限、$ref 解析 |
test_web_app.py |
用 Streamlit AppTest 无头跑通一次完整对话 |
六、小组分工
成员与模块归属
我们组 5 人,代码由一人(谢嘉鸿)统一提交,保证提交历史整洁。下表写的是模块归属,「对应提交」一列与 git log 一一对应,任何人可以按提交号查到谁负责哪部分。
| 学号 | 姓名 | 角色 | 负责模块 | 对应提交 |
|---|---|---|---|---|
| 3124004430 | 林炳辉 | 组长 · 架构与智能体内核 | agent/contracts.py、agent/registry.py、agent/agent.py、agent/runtime.py、agent/memory.py;主循环三条保证与接口约定 |
chore: 初始化…、feat(core)…、feat(agent)… |
| 3124004426 | 黄国轩 | 工具实现与图像生成 | agent/tools/*(8 个工具)、agent/tools/fonts.py、agent/sandbox.py;中文字体探测链、Pillow 版式 |
feat(tools): 安全计算器…、feat(tools): 天气查询…、feat(tools): 中文图表…、feat(tools): 沙箱化… |
| 3124004443 | 吴俊达 | MCP 协议与集成测试 | mcp_server/*、agent/mcp_host/*、tests/test_mcp_smoke.py、tests/test_schema_parity.py;端到端冒烟与降级链验证 |
feat(core)…、feat(mcp)… |
| 3124004444 | 谢嘉鸿 | 规划器与大模型对接 | agent/planner/*(六段流水线)、agent/llm/*、agent/persona.py;意图打分、槽位抽取、LLMPlanner 与自动回退 |
feat(planner)…、feat(ui)… |
| 3124004453 | 张志远 | 界面、文档与演示 | agent/ui/cli.py、agent/ui/render.py、agent/ui/web_app.py、docs/*、scripts/*;环境自检、8 组演示脚本与执行记录 |
feat(ui)…、docs… |
七、成员心得
7.1 林炳辉(3124004430)
分工时我觉得主循环最好写:不就是「调工具 → 拿结果 → 再调」吗?真正写起来才发现,最难的不是循环,是判断多步到底是真的还是假的。
我们最初的版本是这样的:规划器一次返回两个 ToolCall,主循环循环执行两次。看起来走了两步,但实际上第二步的参数在规划的那一刻就定死了——那时候第一步还没执行,数据根本不存在。演示时它确实画出了图,但画的是空图。这种「假多步」最可怕的地方是它看起来是工作的,只是结果不对。
改成真多步后,主循环变得很短:
for step in range(max_steps):
decision = planner.plan(ctx) # 每次只做一个决定
if decision.final_text is not None:
return ...
for call in decision.calls: # 实际只有一个
result = self._invoke(call)
ctx.observations.append(result)
代价是我必须把「进度」这个概念处理干净。最开始我让规划器自己记状态,结果同一轮里被调用两次就错乱。后来改成用 len(ctx.observations) 判断进度,规划器变成无状态的,问题就消失了。这件事让我理解了一句话:能在数据结构里表达的,就别放在对象状态里。
另一个坑是 PlanContext 里那个 observations 列表。我在测试里把 ctx 存起来事后检查,断言一直失败——因为它是同一个列表对象,主循环会继续往里追加,事后再读到的已经不是当初的样子。后来改成当场快照长度,才测出真实行为。这个 bug 让我第一次意识到「测试本身也会骗人」。
7.2 黄国轩(3124004426)
中文图表这个需求看着最简单,实际花了我最多时间。
第一版我只判断字体文件是否存在:
if Path("C:/Windows/Fonts/msyh.ttc").exists():
font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 32)
在本机跑得好好的。后来换到一台精简过的机器上,标题全变成了方块 □□□。原因是字体文件可能在,但对应的字形缺了;.ttc 还是字体集合,索引传错也可能取到没有汉字的那个。
改成真的渲染一次看结果:
canvas = Image.new("L", (56, 56), 0)
ImageDraw.Draw(canvas).text((6, 6), "测", font=font, fill=255)
if canvas.getbbox() is None:
continue # 这个字体画不出汉字,换下一个
这个思路后来也用到了 matplotlib 上。虽然 getbbox() 对空心方框也会返回非空,但配合候选链逐个试,实际效果很好——现在这张机器上五个字体全绿。
第二个坑是ASCII 预览反了。我把图片转字符画时用了「越亮越密」的梯度,结果白底图表在终端里糊成一整块 @@@@。后来加了一行:算平均亮度,浅底就翻转梯度。
第三个坑是小恐龙的背刺。我先画背刺再画身体,被身体盖住了;挪到头顶又被头盖住;挪到肩膀,看起来像两只绿耳朵——和黄色犄角打架。折腾了三次之后我把它删了。有些东西加不上就是不该加。
7.3 吴俊达(3124004443)
我负责 MCP 部分。查了一堆中文教程,写法都是:
from mcp.server.fastmcp import FastMCP
照着写完,pip install mcp 之后运行:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'
查了才知道现在装的是 2.3.0,v2 把 FastMCP 改名成 MCPServer,而且旧路径是直接删除、不是弃用。客户端那边更彻底:v1 的三层 stdio_client + ClientSession + initialize() 被合并成了一个 Client(StdioServerParameters(...))。
这件事给我两个教训:
- 中文教程普遍滞后,遇到 ImportError 先去看官方 changelog,别怀疑自己
- 依赖要锁版本。我们现在写
mcp==2.3.0,因为 2.x 和 1.x 完全不兼容,不锁版本等于把「能不能跑」交给了别人的发布时间
第二个坑是我自己撞出来的,任何文档里都没有:从服务端拉工具列表后我写了:
for tool in listed: # 报 'tuple' object has no attribute 'name'
print(tool.name)
ListToolsResult 是 pydantic 模型,直接迭代得到的是 (字段名, 值) 元组。必须写 listed.tools。
第三个坑更隐蔽:我让工具返回 dict,想用客户端的 structured_content 直接拿结构化结果,结果它一直是 None。实测发现只有返回 str 才有 structured_content。于是我们把所有工具统一成返回 JSON 字符串,客户端从 content[0].text 解析——两种通道(MCP / 本地)的结果结构因此完全一致,主循环一行分支都不用写。
最后一个坑是 Windows 上的:Path.symlink_to() 在没有开发者权限时既不报错也没真的建立链接。我的越界测试因此一直「通过」,其实是假阳性。加上 if not link.is_symlink(): pytest.skip() 之后才诚实。
7.4 谢嘉鸿(3124004444)
我负责规划器。最难的问题不是「怎么匹配关键词」,而是怎么让人相信它真的在理解。
第一版我写的是 if-else:
if "天气" in text: ...
elif "算" in text: ...
跑演示时被问了一句「天气之子好看吗」,它老老实实去查天气了。这暴露了关键词匹配的根本问题:它没有「不匹配」的能力。
后来改成五路加权打分,其中两路是关键的:
- 负分:
("天气之子", -8.0),专门用来表达「虽然字面上像,但不是」 - 上下文延续:上一步查了天气、这一步说「画图」,给 +2.0。这是「然后画成柱状图」能命中的唯一原因
我还做了一个 --debug 参数,把每一分怎么来的打出来:
get_weather: 5.23 (trigger=+3.63, object=+1.00, diversity=+0.60)
make_ascii_art: 0.80 (object=+0.80)
这个输出救了我很多次。有一次准确率卡在 92%,看不出哪里错,打明细才发现「帮我决定吧」被打分器处理时,帮我 被当成礼貌用语剥掉了,剩下的「决定吧」匹配不上任何触发词。加一个「决定」的触发词就好了。
第二个大坑是离线开关静默失效。命令行传 --no-network,我修改的是 build_agent 里的局部 settings 对象,但工具那边读的是 get_settings() 这个全局单例——两个不同的对象。代码不报错,只是不生效。发现它是因为演示时断网了,天气却还在返回真实数据。
修的时候还发现一个更麻烦的问题:MCP 服务端是子进程,它拿不到父进程的 Python 对象。所以离线开关只能通过环境变量传。最后的方案是 use_settings() 同时做两件事:更新单例 + 写 AGENT_OFFLINE 环境变量。
第三个体会是文档比代码难写。写博文时我反复问自己:「读者凭什么相信 via=mcp 不是我们编的?」最后决定把验证方法也写进去——tasklist 看子进程、/trace 看 channel。能被别人验证的说法,才有说服力。
7.5 张志远(3124004453)
我负责命令行界面、网页端和全部文档。
一开始我把界面想简单了:不就是打印几行字吗?真正难的是让它在任何一台机器上都能跑起来。我们组五个人,五台电脑,Python 版本不一样、有的没装 matplotlib、有的字体被精简过、还有的连不上 GitHub。每次换机器演示就出点新状况,最惨的一次是答辩彩排前发现中文标题全变成方块。
后来我不修 bug 了,改写一个 scripts/check_env.py——把 Python 版本、依赖包、中文字体、网络连通性、MCP 握手一次全查一遍,红绿一目了然。写完这个脚本,我们组的「环境问题」反馈直接归零。我学到的东西是:与其每次救火,不如把「怎么判断环境对不对」这件事本身做成一个工具。
第二个体会来自 Streamlit。第一次跑网页端,每问一个问题,任务管理器里就多一个 python.exe——因为 Streamlit 每次交互都会重新执行脚本,我的 Agent 被反复初始化,每次都新起一个 MCP 子进程。查了文档才知道要用 @st.cache_resource。加上之后进程数就稳定在「主进程 + 一个 MCP 服务端」了。这让我第一次意识到网页框架的执行模型和命令行完全不同,同样的代码换个壳就可能泄漏资源。
第三件事是关于写文档的。我一开始写的是「本系统支持八种工具调用」,写完自己都不想看第二遍。后来改成写我们踩了什么坑——比如「网上教程里的 FastMCP 装不上」「字体文件存在但画不出汉字」「返回 dict 时 structured_content 是 None」。这些内容才真正有用,因为它们是搜索不到、只能自己撞出来的。文档的价值不在于描述你做了什么,而在于记下别人没告诉你的那些事。
最后一点小组协作上的感受:我们五个人是先一起定了三个数据结构(ToolSpec / ToolResult / PlanDecision)才开始分头写代码的。这个决定看起来花了额外时间,但实际上让五个人可以完全并行——每个人只依赖这三个约定,不用等别人的代码。这是我这次作业在「工程方法」上最大的收获。

浙公网安备 33010602011771号