Skip to content

Appendix 03 / 附录

全书动手核验清单 ​

小A和老Z动手测试一台小型 Agent,把观察到的证据卡放在结果旁核对。

小A的同事问他:“这本书你读完了,可你凭什么说你懂了?”

小A愣了一下。章节他确实全读完了,源码也跟着走查了一遍,可读过和懂了之间差在哪,他说不上来。他回去找老Z。

“差的就是动手核验。”老Z把各章末尾的练习汇总成这本清单,“读完不算数,做出证据才算数。每章末尾的动手核验,都要求你自己产出可观察的结果:不是回答‘它做了什么’,而是证明‘我让它做了,而且我看到了’。这本附录服务全书:概念篇检验边界理解,源码篇复现调用证据,实现篇验证产物。还有一条要记住,别把模型的一次输出当成结论。”

小A抢答:“我懂了,把每章末尾的练习挨个做一遍呗?”

“做只是第一步。”老Z说,“按顺序做,每一项都要落三个东西:做了什么、看到了什么、说明了什么。‘做了什么’是操作,‘看到了什么’是证据,‘说明了什么’是结论。三项缺一项,核验就不完整;最容易犯的错是跳过中间那项直接写结论,那等于拿猜的当验的。做完一项,在旁边打钩并写上证据摘要,这本清单才成为你的记录,而不是别人的文字。”

小A又问:“清单上的每一项都得做吗?”

“不是每一项都必须,但每一类至少要有一项做扎实。”老Z说,“这本清单分三类:概念核验验证边界理解,源码走查验证阅读证据,实现核验验证产物可运行。三类各做一两项,概念、阅读和动手三个层面的掌握就都有证据了。只挑自己会的做,那不算核验。”

概念核验:边界不是定义,是测试 ​

概念篇的核验分三组,每组对应一条容易混淆的边界。

“概念篇的核验有个共同特点,”老Z说,“每一项都在测一条边界,而不是测一个功能:威胁模型测‘概念边界’,检索测‘忠实性边界’,工具与循环测‘权限和停止边界’。做的时候盯着边界看:哪个测试把边界显式画了出来,哪个测试就把概念变成了可观察的行为。”

威胁模型与安全边界 ​

  1. 为一个“写文件 Agent”列威胁模型:列出资产(要写的文件、工作区外文件)、攻击面(用户输入、工具参数、提示注入)、主体(Agent 进程、用户、模型)与可接受损失。证明威胁模型、授权、审批、隔离是四个不同的概念,它们互补但不可互换。
    • 预期:你能指着清单说出“这一项属于威胁模型,那一项属于授权”,而不是把它们混成一团。
    • 失败信号:如果发现某条同时属于两个类别,说明类别边界没划清。
  2. 设计一个“可外发网络请求”的工具:为它写输入 schema、确认记录与拒绝结果。验证仅靠提示词中的安全规则不能强制执行:模型可以无视提示,但工具执行器拒绝时模型无法绕过。
    • 预期:移除提示词中的安全规则后,工具执行器仍然拒绝危险请求;移除执行器检查后,模型会尝试越权。
    • 失败信号:如果模型“听话”只靠提示词,说明安全全在软约束上。
  3. 构造一段提示注入:把恶意指令藏在一个工具返回的文件内容里,观察模型是否被诱导。记录模型的实际行为,不要假设“模型应该能识别”。
    • 预期:至少能看到一次模型被注入影响的行为(哪怕只是语气变化),以此证明注入是真实风险。
    • 失败信号:如果只是“假设模型不会被骗”,那这个测试就没做。

“前三项做完,合起来看一遍。”老Z说,“它们共同验证的是‘安全靠层,不靠词’:第一项把四层概念分开,第二项证明工具层是硬约束而提示词是软约束,第三项证明提示词这条软约束确实会被攻破。要是做完发现安全全在提示词里,这三项就没白做。”

检索与引用 ​

  1. 用十篇带日期与权限标签的文档测试关键词检索、向量检索和混合检索。人工核对引用:验证召回的不只是文本相似,元数据(日期、权限)是否被正确使用。
    • 预期:三种检索各有漏召:关键词漏语义近似,向量漏精确术语,混合最好但仍非完美。
    • 失败信号:如果某一种检索“全对”,检查测试样本是不是太简单。
  2. 设计一个“答案不在文档里”的查询,观察检索系统是承认缺失还是编造引用。这是 RAG 忠实性的核心测试。
    • 预期:系统输出“未找到相关资料”或类似的不确定性表达。
    • 失败信号:如果它编造了一个看似合理的引用,说明忠实性有问题。

“第四、五项是一对。”老Z说,“第四项测召回能力,第五项测承认局限:前者看它找得全不全,后者看它找不到时会不会硬编。两项可以一好一差:召回好但会硬编,危险在引用造假;召回差但会认怂,安全但没用。做完这两项,你对自己的 RAG 是‘能找对但爱撒谎’还是‘找不全但很诚实’,心里就有数了。这两种都还有得救,最糟的是又找不对又爱编。”小A在第五项旁补了一句:“诚实性比召回率更接近信任的底线。”

工具与循环 ​

  1. 为一个只读工具写测试,再为一个会修改文件的工具写测试。验证只读工具不需要审批,而修改工具需要:这一差别来自副作用,不来自工具名。
    • 预期:只读工具直接执行;修改工具触发审批或确认点。
    • 失败信号:如果两者执行路径完全相同,说明审批边界没设计。
  2. 让一个 mini-agent 循环跑到 max_turns:记录它停止前的最后几步,分析它为什么没收敛。pi v1.0.0 的 runAgentLoop 没有 maxTurns 参数,不要把这项核验搬到 Pi 源码上。大多数循环问题都在预算耗尽时暴露,这是循环设计最现实的测试。
    • 预期:循环在达到预算时停止,并记录明确的停止原因(max_turns 而非 complete)。
    • 失败信号:如果它静默停了或没有停止原因,说明状态机有缺陷。

小A指着第七项问:“记录停止前的最后几步,是想看出什么?”

“回答一个问题:它是‘差点收敛’,还是‘根本没在收敛’?”老Z说,“前者说明预算差一点,调高 maxTurns 可能就好了;后者说明循环设计有问题,加预算只是多烧钱。判断依据是停止前的趋势:每轮都在缩短任务差距,是差点收敛;每轮都在重复同样的动作,是原地打转。这个区别,决定你是调参数还是改设计。”

“那第六项和第七项呢?”

“第六项证明审批边界有设计,第七项证明停止边界有状态。前者管‘能不能做’,后者管‘什么时候停’,两个边界都成立,Agent 才既不会乱来,也不会空转。只做了第六项,它可能很守规矩但停不下来;只做了第七项,它可能停得干脆,可什么都敢做。”

“还有一条补充:跑 max_turns 前先记一次 token 基线。”老Z说,“跑完对比最后一步的累积用量和预期用量,差距越大,说明循环在收敛路径上浪费越多。这个数字不直接改代码,但它能告诉你,是先去看工具失败信息,还是先调预算。”小A在第七项旁边写了“先看趋势,再调预算”。

源码走查:复现调用证据 ​

在上游 pi 仓库检出标签 v1.0.0 后,在根目录执行以下命令,先确认你确实在正确的基线上:

sh
git rev-parse HEAD
# 应输出 a13d35a742c6ef8462812a28fbe1d8c8b7431c32(或以 a13d35a7 开头)

也可以直接读本书仓库的 pi/ 目录:它是同一标签的快照,路径相同,但加了中文注释,行号会比上游靠后。

确认基线后,逐段阅读核心文件(命令给出起始行,方便定位):

sh
sed -n '1,260p' packages/ai/src/models.ts        # 模型请求主路径
sed -n '1,260p' packages/agent/src/agent-loop.ts  # 循环核心
sed -n '1,260p' packages/protocol/src/codec.ts    # 协议编解码
sed -n '1,330p' packages/server/src/server.ts     # 远程握手与请求路由
sed -n '1,200p' packages/evals/src/harness.ts     # 评测 harness

“先做两步前置确认。”老Z说,“第一,git rev-parse HEAD 的输出必须以 a13d35a7 开头,不是这个基线,下面的行号全都对不上,别硬套。第二,sed 命令只是给了起始行,文件可能比 260 行更长,要读到文件末尾为止,别在 260 行就停。行号是路标,不是边界。两步做完才开始走查,走查的每一项,都要能说出‘我在哪一行看到了什么’。”

小A问:“基线对不上会怎样?”

“最常见的两种情况。”老Z说,“一是你 clone 的是最新 main,行号整体漂移,这时要么 checkout 到 v1.0.0,要么接受行号作废、只看符号名;二是你改过本地代码,行号局部偏移,这时只信你手头文件的真实行号。基线的意义是‘结论可复现’:别人在你的记录里看到行号,换台机器也能定位到同一段代码。”小A把基线检查写成了走查前的第一动作。

阅读时完成以下走查任务,每项都写下你观察到的具体行号或符号:

  1. 认证与流交接:从 ModelsImpl.streamSimple 标出认证合并(applyAuth)、Provider 选择(requireChatProvider)与事件流返回这三个交接点。说明认证错误发生在哪一步,而不是由 TUI 猜测。
    • 预期:三个交接点的行号各自落在一处,你能指出认证错误是在 applyAuth 抛出的,而不是由调用方凭空判断。
    • 失败信号:如果只能说出“认证会失败”,却说不出在哪一步失败,说明还没把交接点钉在具体代码上。
  2. 循环的停止原因:在 runLoop 中找到 assistant 的 stopReason 为 error/aborted、length、带工具调用、不带工具调用时各走哪段代码,再找到 finishTurn 返回 { action: "end" } 时的退出点。验证工具 error 不是 transport error,仍作为 tool result 回到模型。
    • 预期:每种停止形态各对应一段可指认的代码;工具 error 走的是 tool result 通道,而不是被当成连接错误抛掉。
    • 失败信号:如果某个停止原因找不到产出点,或工具 error 被混进 transport error,说明还没分清“工具层失败”和“协议层失败”。
  3. chunk 边界 ≠ 消息边界:分两次向 ClientMessageDecoder.push 送入同一个 frame 的两半,验证第一次返回空数组、第二次才产出消息。这是网络分帧的真实行为。
    • 预期:第一次 push 返回 [],第二次 push 返回完整消息;记录两次返回的数组长度。
    • 失败信号:如果第一次就返回了消息,说明实现把 chunk 当成了消息,分帧语义理解错了。
  4. 会话 fork 不创建 worktree:对照 packages/coding-agent/src/core/agent-session-runtime.ts 的 fork 与 session-manager.ts 的 createBranchedSession,确认会话 fork 只复制 session entry,没有调用任何 Git 或文件系统隔离 API。
    • 预期:这两处找不到任何 git、worktree 或目录复制调用;fork 前后的工作区文件没有任何变化。
    • 失败信号:如果 fork 后工作区文件被复制或修改,说明 fork 的边界比你以为的更宽,需要重新界定。
  5. 订阅快照与增量的先后:在 server.ts 的 handleRequest 中找到订阅分支,标出快照随 response 返回、之前到达的更新暂存在 pendingUpdates、response 之后再补发 service_update 的三处代码。不宣称本快照实现自动重连或 exactly-once。
    • 预期:快照一定先于该订阅的任何 service_update 到达客户端;客户端在 client.ts 的 subscribeService 里也有对应的缓冲。
    • 失败信号:如果找不到暂存逻辑,或把 service_update 当成了权威状态源,说明对“快照定稿、增量补充”的顺序还没建立。
  6. compaction 的失败语义:在 packages/coding-agent/src/core/compaction/compaction.ts 中找到 getSummarizationFailure,写出 error 和 length 两种 stop reason 各自产生的错误文本,再找到摘要里出现工具调用时的错误。验证压缩是可能失败的独立请求,不是静默截断。
    • 预期:每种失败都能对回一段抛错代码;压缩失败时 appendCompaction 不会执行,而不是无声地把历史裁掉。
    • 失败信号:如果找不到这些失败判定,或压缩失败被静默吞掉,说明看到的是简化版实现,不是 pi 的失败语义。

“走查的产出物,不只是一张写满行号的纸。”老Z说,“最好是一份‘代码-行为对应表’:左边是你观察到的代码,行号加一两句话;右边是它对应的可观察行为。比如‘applyAuth 在 132 行抛出认证错误’对应‘未配置凭据时,请求在模型层就失败,UI 不该猜测’。这张表以后就是你的排障地图,代码变了,表要跟着更新;行为变了,第一个被翻出来对照的就是它。”

实现核验:产物可运行 ​

实现篇的核验围绕 examples/mini-agent 展开。这个产物的验收标准是:代码可运行、测试可通过、行为可观察。

“实现核验和源码走查有个关键区别。”老Z说,“走查是在别人的代码里找证据,实现核验是你自己的产物要扛得住证据。走查证的是‘pi 是这样做的’,实现核验证的是‘我做的也能这样跑’。所以这里的失败信号更值钱:走查发现‘源码和我想的不一样’是收获,实现发现‘我的产物不达标’才是该修的。”

“这五项串起来看,各有分工。”老Z接着说,“路径边界决定它能碰什么,取消决定它停不停得下来,cleanup 决定它走不留痕迹,错误分离决定它坏得清不清楚。一个 Agent 产物的成熟度,就看它停得干不干净、坏得明不明白。五项的失败信号里,前四项是行为缺陷,第五项是可诊断性缺陷;后者最容易被拖到最后,但它恰恰是排障的地基。”

  1. 跑通测试:在 examples/mini-agent 下执行测试命令,确认全部通过。记录测试数量与耗时,不要只看“pass/fail”。
    • 预期:全部测试通过,测试数与本书声称一致。
    • 失败信号:如果有测试失败,先确认是环境问题还是产物问题,不要跳过失败测试。
  2. 只读工具与路径边界:为最小 Agent 写一个只读工具(如 read),再写一个测试,验证它拒绝工作区外路径(用 .. 或绝对路径尝试越界)。确认拒绝来自 workspacePath 的 realpath 检查,而不是来自提示词。
    • 预期:.. 越界、绝对路径越界、符号链接绕过都被拒绝,且拒绝发生在工具层。
    • 失败信号:如果路径检查靠正则前缀匹配,符号链接会绕过它。
  3. 取消的可诊断性:使一次模型流被取消(通过 AbortSignal),确认退出码、日志和会话状态都可诊断。你要能从这三处分别看到中止的痕迹,而不是它们静默地“什么都没发生”。
    • 预期:日志有中止记录、会话状态标记为 aborted、退出码反映中断。
    • 失败信号:如果取消后状态显示“完成”,说明取消没真正传播。
  4. cleanup 无残留:用临时目录运行集成测试,验证 cleanup 后临时目录被递归删除,且真实模型测试与 fake 测试分开标记。fake 测试不消耗 API 配额,真实测试结果不能伪装成确定性证明。
    • 预期:临时目录不存在;fake 和真实测试在报告里分开列出。
    • 失败信号:如果残留目录存在,说明 cleanup 的 finally 块有缺陷。
  5. 错误类型分离:对工具失败、模型错误、协议错误分别断言不同的错误类型,避免统一吞成空结果。这是排障的基础:所有错误都长一样,就永远定位不到故障层。
    • 预期:三类错误各有不同的错误类型或 code,调用方能区分。
    • 失败信号:如果所有错误都返回同一个类型,排障时无法定位故障层。

“第五项做完,补一个小的自检。”老Z说,“把三类错误打印出来看形状:工具失败的错误里,有没有带上工具名和调用 ID?模型错误里,有没有 Provider 名和 stop reason?协议错误里,有没有层信息?类型分得再开,形状光秃秃,排障照样没路可走。”小A在第五项旁写了“类型 + 上下文,缺一不可”。

核验的纪律 ​

小A做完这些,问老Z:“做完一次就够了?”

“不够。”老Z说,“核验不是一次性考试,是持续纪律。每次改了 prompt、换了模型版本、加了工具,都要重新跑相关核验。模型行为会随版本漂移:同一个 prompt,这个版本答对,下个版本可能答错;工具链升级,路径边界也可能悄悄变宽。清单上每一项都是插在系统里的探针,环境没变,它安静躺着;环境一漂移,它就报错。清单检验的不只是当初做对了没有,更是每次环境变动之后,现在还对不对。”

小A想了想:“那这套清单,什么时候算真正‘拥有’了?”

“当你能不看清单,自己说出每一项要测什么、失败信号是什么的时候。”老Z说,“清单是训练轮,训练轮的意义是让你学会自己骑。学会之后,面对新的系统、新的风险,你要能自己设计新的核验项。照单做是做作业,会改单才是掌握了方法。这本书教你的不是这二十几项,是‘怎么给一个概率性系统设计核验项’这件事本身。”

“给这条纪律配一个节奏:大改跑全套,小改跑相关。换模型版本、改 system prompt、改核心循环,跑全套,因为影响面横跨所有章节;加一个工具、改一个工具描述,只跑相关项,因为其他核验覆盖不到它的行为。全套和相关怎么分,是经验活;无论怎么分,原则只有一个:改动过的链路,必须有对应的核验回执。”

小A又问:“那核验记录怎么存,才能让重新跑变得便宜?”

“存成脚本,别存成段落。”老Z说,“能命令化的核验(跑测试、跑走查、跑对比)一律写成脚本,放进仓库;不能命令化的人工判断,存结论模板:‘我改了 X,观察到了 Y,据此判断 Z’。下次重跑,脚本一次跑完,人工项照着模板快速过一遍。核验记录的价值,在于能低成本地重来一次。”

核验的三条纪律

  1. 证据优先于信心:写下你观察到的行号、输出、耗时,而不是“我觉得它对了”。
  2. 失败路径优先于成功路径:成功路径谁都会测,故障手册里的每个症状都要能复现。
  3. 真实与 fake 分开标记:fake 测试证明逻辑正确,真实测试证明链路可用,两者证据等级不同,不能混报。

小A指着第二条:“‘失败路径优先’,能举个具体的例子吗?”

“能,比如取消。”老Z说,“成功路径是请求完成、返回答案,谁都会测;失败路径是请求中途被取消,状态对不对,测的人少一半。你测一次取消,胜过测十次成功:成功路径只证明‘没坏’,失败路径证明‘坏了也知道怎么坏’。故障手册里每一项症状,都值得在清单里配一个对应的复现测试。”

回到全书的目标:这本清单最终要建立的,是一个可重复的验证习惯,读完后你能独立复现证据,不依赖书里的结论。

小A把清单折好夹进书里。他知道,这本清单的终点不在最后一页的打钩,而在下一次改 prompt、换模型、加工具时,能自动想起那句“改动过的链路,必须有对应的核验回执”。动手核验没有毕业,只有一次又一次的重跑。