第 15 章 毕业设计:从 mini 到真的
本章代码
code/ch15/checkup.py(毕业自检脚本) · 读完约 20 分钟
15.0 上一章留下的问题
十四周下来,这个助手已经具备了:会对话、会调工具、会等人确认、能被打断、能持久化、能算账。你本地跑得很顺。
然后你把它发给同事。十分钟后收到三条消息:
"启动报错,说找不到
agentscope。" "界面能打开,但发消息一直转圈。" "它把我桌面的文件删了。"
这三条和代码质量都没关系。它们对应的是三个不同的缺失:环境没有固化、错误没有暴露、边界没有收紧。功能正确只是及格线,本章讲的是从"我这儿能跑"到"别人能用"之间还差的那几步。
15.1 六项毕业自检
不看功能列表,只看能不能过下面六项。它们覆盖了这个助手的全部关键路径,任何一项挂了,都属于"还不能交出去"。
| # | 检查项 | 判断标准 | 对应章节 |
|---|---|---|---|
| 1 | 对话通路 | 收到文本增量,REPLY_END 的 finished_reason 是 completed,MODEL_CALL_END 有非零 token | 2、11 |
| 2 | 工具落地 | 允许之后文件真的出现在磁盘上 | 3、6 |
| 3 | 权限拦截 | 拒绝之后文件不存在,且流程继续而不是崩掉 | 7、13 |
| 4 | 中断 | 运行中叫停,收尾是 interrupted,上下文不被清空 | 13 |
| 5 | 状态往返 | AgentState 序列化再读回来,消息数与权限模式不变 | 9 |
| 6 | 记账 | 用量写进 JSONL,费用不为零 | 14 |
code/ch15/checkup.py 把这六项自动化了。它不启动 Web 服务,直接在进程内驱动会话——自检脚本自己不能因为端口、浏览器、前端缓存而失败。
cd code/ch15
python checkup.py模式:假模型(MOCK=1)
工作目录:...\code\ch15
------------------------------------------------------------------------
1/6 对话通路 ✓ 13 帧,180/3 tok
2/6 工具落地 ✓ ...\code\ch13\sandbox\notes.md 41 字节
3/6 权限拦截 ✓ 拒绝生效,文件未被创建
4/6 中断 ✓ 停在 5 帧,上下文保留 2 条
5/6 状态往返 ✓ 1805 字节 / 2 条消息
6/6 记账 ✓ 1 行,180 tok,¥0.000384
------------------------------------------------------------------------
六项全过。接下来看第 15.2 节,选一条升级路线。自检脚本不另搭一套 Agent,它直接复用第 13 章的 server.Session 和工具装配——所以第二项落盘的位置是 code/ch13/sandbox/notes.md,不是 code/ch15/ 下面。这样"毕设过没过"和"平时用的那个服务"是同一份代码,自检才有意义。
关键在它的写法。核心是一个"探针会话",把每一帧都留下:
class Probe(ch13.Session):
def __init__(self, session_id, agent, recorder=None):
super().__init__(session_id, agent)
self.events = []
self.recorder = recorder
def _emit(self, event_type, data):
self.events.append((event_type, dict(data)))
if self.recorder is not None:
self.recorder.observe(event_type, data, self.session_id)
super()._emit(event_type, data)覆盖一个 _emit,六项检查全都能做——因为这个系统里发生的一切都会变成事件。这是第 11 章那个决定的回报:当时把事件流设计成唯一的对外通道,现在它同时是前端的数据源、记账的数据源、以及自动化测试的观测点。
三个检查里有值得单独说的细节。
第 2、3 项共用同一个开头。 都让模型写文件,都在 REQUIRE_USER_CONFIRM 处停下,区别只在回应时 confirmed 是 True 还是 False。这是权限系统的正确用法:一段代码路径,两种结果,靠输入区分。如果"拒绝"要单独走一条分支,说明权限判定被写进了业务逻辑里。
取出待确认的调用之后必须清标记。
def take_pending(session):
pending = session.confirm_pending
session.confirm_pending = None
return pending不清的话,下一轮收尾时 _run 的 finally 会发现"还有待确认的调用",于是发 confirm_required 而不是 done。前端会一直停在"等你确认",而后端已经没有待确认的东西了。这是第 11 章那个 finally 分支的副作用,写测试时很容易撞上。
中断那项要先把模型调慢。 假模型默认每 8 个字睡 20 毫秒,一轮跑完不到 0.3 秒,asyncio.sleep(0.2) 之后任务可能已经结束了,task.cancel() 取消一个已完成的任务什么也不会发生,测试会假通过。脚本里临时把 MOCK_DELAY 调到 0.4 秒,跑完再调回去。
要对着真模型跑自检,加一个开关:
python checkup.py --real模式:真模型
------------------------------------------------------------------------
1/6 对话通路 ✓ 30 帧,752/24 tok
2/6 工具落地 ✓ ...\code\ch13\sandbox\notes.md 81 字节
3/6 权限拦截 ✓ 拒绝生效,文件未被创建
4/6 中断 ✓ 停在 6 帧,上下文保留 2 条
5/6 状态往返 ✓ 1905 字节 / 2 条消息
6/6 记账 ✓ 1 行,752 tok,¥0.001712
------------------------------------------------------------------------
六项全过。帧数从 13 涨到 30、token 从 180 涨到 752,是因为真模型会先输出一段思考、再把话说长。两个模式都过,说明代码对"模型怎么回答"没有暗含假设。
第二项对着真模型跑时踩过一次坑,值得记下来。真实模型面对"帮我写文件 notes.md"这句请求,并不总是直接调 Write——它先调了一次 ask_user,问"两条待办具体写什么内容"。于是自检脚本拿到的待确认调用是 ask_user,不是 Write,检查项 2 就挂了。
对策不是把提示词写得更死,而是让脚本学会处理这种合法的中间轮:一个 drive_to() 循环,只要待确认的工具不是目标工具、且它是提问,就替用户答一句,然后继续等,直到 Write 出现。
async def drive_to(session, wanted, prompt):
"""一路把 ask_user 都答掉,直到 wanted 这个工具的确认请求出现。"""
...这条经验适用范围比自检更大:只要模型参与决定"下一步做什么",任何按固定步骤写的断言都会间歇性失败。 稳妥的写法有两种——要么用假模型把输入固定住(所以本章保留 MOCK 模式),要么把断言写成"最终一定会到达某个状态",而不是"第 N 步一定是某个动作"。
15.2 三条升级路线
六项都过了,接下来是选择题:这个 mini 版本往哪儿长。三条路,适用场景完全不同。
15.2.1 路线一:agent-service,解决"多人用"
单进程 + 内存字典的会话注册表,两个用户同时用就开始互相干扰:一个人刷新页面,另一个人的流断了;进程重启,所有人的会话都没了。
官方有一个 create_app(),把"会话存哪、消息怎么分发、工作区怎么隔离"这三件事做成可替换的组件:
from agentscope.app import create_app
from agentscope.app.message_bus import RedisMessageBus
from agentscope.app.storage import RedisStorage
from agentscope.app.workspace_manager import LocalWorkspaceManager
app = create_app(
storage=RedisStorage(host="localhost", port=6379, db=0),
message_bus=RedisMessageBus(host="localhost", port=6379, db=0),
workspace_manager=LocalWorkspaceManager(basedir="./workspaces"),
)注意它的参数里没有 agent_factory。它接管的是基础设施,不是你的 Agent 装配逻辑——Agent 通过应用自己的配置来定义。所以走这条路不是"把现有代码搬进去",而是换一套组织方式:你写的 build_agent()、Session、Registry 大部分会被它内部的实现替代。
工作区管理器那一栏选项很多(Docker、E2B、K8s、Bubblewrap),本地开发用 LocalWorkspaceManager 就够,要真隔离再换。
什么时候走这条路:有两个以上的人要同时用,或者你需要重启服务而不丢会话。 代价:多一个 Redis,多一套部署;而且要接受"部分代码交给框架"。一个人用不上。
15.2.2 路线二:从单兵到小队
现在是一个 Agent 干所有事。任务变复杂之后,一个 system_prompt 里塞不下所有角色——它既要是严谨的写手,又要是细心的审查者。塞在一起的代价是每一样都做得平庸。
2.0.8 起有 GoalPipeline:一个执行者,一个验收者,跑到达标为止。
from agentscope.pipeline import GoalPipeline
pipeline = GoalPipeline(
executor=writer_agent, # 负责产出
verifier=reviewer_agent, # 负责挑毛病,给出"达标 / 不达标"
verifier_reset_context=True, # 验收者每轮从干净上下文开始,只看当前稿
max_iters=10,
)verifier_reset_context=True 是这里的重点。验收者不需要看见写作过程中的全部尝试,只需要看见当前这一稿——单 Agent 做不到这一点,因为它的上下文是连续的,写过的废稿会一直干扰判断。
2.0.9 起有更通用的 TeamPipeline:一个 Leader 拆活,分给多个专职成员。形状不同,解决的还是同一个问题——用上下文隔离换取判断质量。
什么时候走这条路:一个任务的输出需要经过"生成—评审—修订",或者需要几种明显不同的专业视角。 代价:token 消耗成倍增加(每个角色都有自己的上下文),调试难度上升一个量级(出错时要先判断是哪个角色错了)。
15.2.3 路线三:MCP + Skill,能力扩展不改代码
现在的工具都写死在 Toolkit(tools=[Write(), AskUser()]) 里。每加一个能力就要改代码、重启服务。
MCP(Model Context Protocol)把工具变成外部进程:Agent 连上去,按协议发现对方提供了哪些工具。
from agentscope.mcp import MCPClient, StdioMCPConfig
from agentscope.tool import Toolkit, Write
fs = MCPClient(
name="filesystem",
is_stateful=True, # stdio 传输必须是有状态客户端
mcp_config=StdioMCPConfig(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "./sandbox"],
),
)
await fs.connect() # 有状态客户端要先连上,才能挂进 Toolkit
toolkit = Toolkit(tools=[Write()], mcps=[fs])那两行注释都是硬性要求,写错会直接报错:stdio 的 MCP 客户端 is_stateful 必须为 True(进程要一直活着),而且没连上就传给 Toolkit 会抛 ValueError。用 HTTP 传输的换成 HttpMCPConfig,那种是无状态的,不需要预先连接。
Skill 是另一层:不是给 Agent 新工具,而是给它一份"某种任务该怎么做"的说明,按需加载进上下文。区别在于 MCP 扩展能力边界,Skill 扩展做事方法。
什么时候走这条路:工具种类会持续增加,且增加的速度快过你发版的速度。 代价:多一层进程间通信,出错时的排查链条变长(前端 → 后端 → MCP 进程 → 目标系统)。
三条路不互斥,但不要同时走。先把当前最疼的那个问题解决掉,跑稳了再动第二个。
15.3 版本能力对照表
本书按 AgentScope 2.x 写,开发基线是 2.0.5。各章不再单独标注版本号,不同小版本之间的差异集中列在下表。
| 版本 | 新增能力 | 本书哪一章用到 |
|---|---|---|
| 2.0.5 | 结构化输出、权限系统、六个内置工作区 | 第 3、5、7、10 章 |
| 2.0.6 | 中间件的 on_check_permission 钩子 | 第 10 章(七个钩子之一) |
| 2.0.7 | agentscope.console 调试入口 | 可选,用于看事件 |
| 2.0.8 | realtime、A2A、GoalPipeline | 第 15.2 节路线二 |
| 2.0.9 | TUI、AskUser 内置工具、TeamPipeline | 第 15.2 节路线二 |
| 2.0.10 | WebRTC 支持 | 未涉及 |
如果装的是 2.0.5,第 13 章那个自己写的 ask_user 是必需的;装了 2.0.9 之后官方有 AskUser,可以对照着看两者的实现差异——官方的版本走的是同一条权限通道,思路一致。
查当前版本:
python -c "import agentscope; print(agentscope.__version__)"15.4 常见坑 Top 20
每一条都是这一路上真实撞过的,按"症状"排,方便出问题时对照。
| # | 症状 | 原因 |
|---|---|---|
| 1 | Toolkit 没有 register_tool_function | 用构造函数 Toolkit(tools=[...]) |
| 2 | Msg(name=..., role=..., content="...") 报校验错 | 内容必须是块列表,用 UserMsg(name=..., content="...") |
| 3 | 工具卡片参数一直是空 | 参数在 TOOL_CALL_DELTA 里,TOOL_CALL_START 只有工具名 |
| 4 | ToolCallBlock.input 类型错 | 它是 JSON 字符串,不是 dict |
| 5 | PermissionBehavior 导不进来 | 在 agentscope.permission,不在 agentscope.event |
| 6 | 规则 .append() 之后不生效 | allow_rules 是 dict[str, list],要用 PermissionEngine.add_rule() |
| 7 | 只读工具永远不会停下来问 | 只读放行在 check_permissions 之前,is_read_only 必须为 False |
| 8 | check_permissions 报参数个数不对 | 引擎按位置调用 (tool_input, context),不能写 **kwargs |
| 9 | 回放历史会话只出现第一轮 | 缓冲区里每轮一个 done,别在第一个 done 上退出读循环 |
| 10 | 第二轮发送没反应 | 后端在 done 后关了订阅队列,每次发送后要重新 connect() |
| 11 | 中间件里读 token 全是 0 | 拿到的是流式外壳,真实 usage 在最后一个 chunk |
| 12 | 账目里模型名全是 unknown | model_name 只在 MODEL_CALL_START 上 |
| 13 | 需要确认的工具耗时记为 0、名字是 ? | 续跑没有 TOOL_CALL_START,要在 TOOL_RESULT_START 补 |
| 14 | 中文出现 � | TextDecoder 少了 { stream: true } |
| 15 | 偶发少半句话 | 分帧时 buf 用了清空而不是切掉 |
| 16 | 页面一直"连接中"、输入框锁死 | 出错只处理了 done,没处理 error 帧 |
| 17 | 刷新后思考块自己弹开、滚动条跳顶 | 每个 delta 都 innerHTML 重绘整段 |
| 18 | 新增 API 路由 404 | 静态目录 Mount 是兜底匹配,必须排在最后 |
| 19 | 压缩之后对话"失忆" | trigger_ratio 必须 ≤ 0.9,太大来不及压 |
| 20 | File ... exists but has not been read yet | 先读后写规则,写之前 Read 一次或删掉旧文件 |
第 20 条需要多说一句:它不是 bug,是工作区安全策略的一部分——防止模型在没看过内容的情况下覆盖你的文件。调试脚本反复写同一个文件时会一直撞上它,删掉目标文件或先读一次都能绕过。
15.5 官方文档该按什么顺序读
这本书是"做中学"的路径,官方文档是"查字典"的路径。真正需要翻文档的时候,按下面顺序效率最高:
Agent的 API 文档——构造函数参数、reply_stream的签名。这两个是所有事情的入口agentscope.event的事件定义——每个事件类有哪些字段。前端渲染、记账、测试全靠它,比任何教程都准agentscope.permission——判定顺序和各种行为值。这一块文档写得最细,也最值得细读agentscope.tool——内置工具清单。自己造工具之前先看看有没有现成的agentscope.pipeline——单 Agent 不够用的时候再看,early 阶段看了也记不住
一个实用技巧:文档里的类名可以直接在本地验证,比读文字快。
python -c "from agentscope.event import ToolCallStartEvent as E; print(E.model_fields.keys())"字段是真相,散文是描述。这一路上有好几个坑(工具参数是流式的、MODEL_CALL_END 没有模型名)都是靠读字段而不是读文字才发现的。
15.6 结课
回头看这十五周做了什么:
第 1 章 一次对话 Agent = Model + Loop + Tools
第 2 章 看懂消息结构 内容块、role、事件
第 3 章 装上工具 ToolBase、Toolkit、input_schema
第 4 章 拆开循环 ReAct、最大迭代、停止条件
第 5 章 划出边界 工作区、路径限制、读写规则
第 6 章 自己造工具 自定义 ToolBase、错误返回
第 7 章 上锁 权限模式、规则、判定顺序
第 8 章 长任务管理 计划模式、上下文压缩、任务清单
第 9 章 让会话活下去 状态序列化、恢复、多会话
第 10 章 插手 中间件七钩子、结构化输出
第 11 章 接出后端 FastAPI、SSE、环形缓冲、断线续传
第 12 章 接上前端 事件归位、reducer、流式拼接
第 13 章 闭环 确认、提问、中断
第 14 章 可观测 记账、成本、自检清单
第 15 章 交付 毕业自检、升级路线十五周前,"Agent"是一个玄乎的词。现在它是一个你能拆开、能改、能量化、能交付的系统。
这本书没有教你所有东西。上下文压缩的具体策略、多 Agent 协作的通信协议、MCP 的完整规范,都只是提了一句。但你现在有了一个可以自己往前推的底座——遇到新需求时,你知道该改哪一层,也知道改完之后该去哪里验证。
最后一句建议:把这个 mini-workbuddy 留在手边。以后学任何新框架、新模型、新协议,都用它做第一现场——拿它跑一个最小例子,比读十篇介绍文章都快。
