本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13,随版本演进可能变化。
两件看起来不相干的事:同一个工具结果,模型看的和人看的凭什么可以不一样;以及智能体改文件之前,怎么确保它确实读过这个文件。
贯穿两件事的线索是同一招——把判断依据从"当下看起来是什么"换成"一份可验证的凭证"。
| 场景 | 旧依据(不可验证) | 凭证(可验证) |
|---|---|---|
| 工具结果 | 渲染出来的文本 | schema 校验过的规范值 |
| 文件编辑 | 模型"我记得读过" | 观测账本 + 版本凭证 |
凡是模型说"我记得是这样"的地方,都值得问一句:有没有东西能替它作证。
| 阶段 | 职责 | 位置 |
|---|---|---|
execute() | 产出规范 JSON 值(canonical value) | 工具本体 |
output.schema | 校验该值(强制) | index.ts 第 211–219 行 |
render(args, value) | 纯投影 → 模型内容块(强制) | 同上 |
presentationMeta(args, value) | 纯投影 → 可回放 UI 数据(可选) | 同上 |
presentResult(args, result) | 纯投影 → 一张带 card 标签的卡片 | presentation.ts |
关键约束:render 与 presentResult 都是纯函数,不做 I/O。因为它们在实时流式输出和会话日志回放两条路径上都要跑,跑出来必须一样。只要有一处偷偷读了当前时间或磁盘状态,回放就会与当时不一致。
持久化的 tool/result 事件只存 content、error 和 meta,规范值从不落盘。
回放可以重现每一张卡片和每一段模型文本,却重建不了中间值。
—— docs/subsystems/tools.zh.md「结果仅承载产出」一节
渲染意图(render intent)是带 card 标签的联合类型,值域六种:
generic · terminal · diff · read · search · web
客户端只需对 card 做一次 switch,不需要认识任何工具名。换一个搜索后端 provider,工具实现整个换掉,只要它照样产出 search 卡,UI 一行不用改。
| 渲染长在工具身上(CC 式) | 渲染翻译成数据(DSH 式) | |
|---|---|---|
| 表达力 | 像素级控制 | 受限于六种卡片 |
| 换客户端 | 重写渲染层 | 无需改动 |
| 回放 | 需重新执行渲染代码 | 直接重放数据 |
选型判据:你的渲染需要被"重新执行",还是只需要被"重新展示"?前者放工具里,后者做成数据。
search 卡强制携带 truncated 与 total(presentation.ts 第 223–231 行);read 卡携带 offset 与 totalLines,可画出"显示 N 行,共 M 行"。很多误导人的界面,问题就出在把截断结果画得跟完整结果一个样。
post-execute 放行时,换 content 和换 value 只能二选一。这不是文档约定,是类型定义(index.ts 第 593–600 行 PostToolDecision):
content,同时把 value 的类型标成 never;value,把 content 标成 never;block,把纠正性反馈变成错误结果。TypeScript 中 never 没有任何合法取值——想在一个决定里同时塞两个字段,编译器直接报错。
| 动作 | 层次 | 后果 |
|---|---|---|
| 换 content | 展示层 | 值保持原样,只改模型看到的文本 |
| 换 value | 数据层 | 注册表拿新值重过 schema,重算 content 与 meta,三份投影同源 |
允许同时换 → 出现"文本说 A、值是 B"的分裂结果。
内容替换是展示策略。想对程序隐藏值的插件必须换值或 block——光改文本瞒不住直接拿值的程序。
render 抛异常 / 值没过 schema / presentationMeta 产出非 JSON → 全部转成 JSON 安全的 isError(第 1793 行起 createSuccessResult);presentCall / presentResult → 客户端回退 generic 卡(第 79–83 行注释)。三件套(docs/tool-catalog.zh.md):read(窗口化带行号)、edit(字面量替换)、write(整文件创建或覆盖)。
账本状态只有三种:
| 状态 | 含义 |
|---|---|
| 未见 | 表里压根没这个文件的条目 |
present@vN | 读到过,读到的是版本 vN(后端签发的不透明新鲜度凭证) |
absent | 确认过这个路径不存在(如 read 扑空) |
每次 read / write / edit 成功后,工具发出 fs/observed 事件,插件同步记账。
| 多槽(上一集 pre-execute) | 单槽(本集 fs/write-intent) | |
|---|---|---|
| 语义 | 每位监听器都能表态、能短路 | 只有第一位说话,独占决策权 |
| 适用 | 放行这类顺序敏感判断 | 守卫条件这类必须一人拍板 |
关键:插件只对账本给出守卫条件,真正的检查由后端在原子临界区完成——先验版本再匹配再替换。从查完到写入之间的空隙被消灭了。
凡是"先检查再执行"的逻辑,都值得问一句:中间那段空隙有没有人管。
整个策略插件不到 140 行,核心就是 writeIntent(第 61–71 行)与 editIntent(第 78–88 行)两个查账函数。
| 工具 | 未读过 | 读过 |
|---|---|---|
write | createIfAbsent:不存在则创建,存在则拒绝(FS_NOT_OBSERVED) | replaceIfVersion:版本对上才替换 |
edit | 直接 FS_NOT_OBSERVED;账本记 absent → FS_NOT_FOUND | 带版本守卫上路 |
翻译成人话:新建文件不用先读,覆盖别人的文件不行。
版本检查排在字面量匹配之前,所以拿过期内容编辑报的是 FS_STALE_VERSION,不会退化成误导性的匹配失败。
如果顺序反过来,模型拿到"没找到匹配",会以为自己写错了字符串而反复重试,永远想不到真正原因是文件早被人改过了。
匹配那关:old_string 必须恰好命中一次——多处报 FS_AMBIGUOUS_EDIT,零处报 FS_EDIT_NOT_FOUND,除非显式 replace_all。匹配、行尾处理、陈旧检查、原子替换全在同一临界区内完成(docs/subsystems/filesystem.zh.md 第 151 行)。
ctx.attachments 就不注册 read_image(第 718 行)。| 产品 | 浓度 | 机制 | 证据 |
|---|---|---|---|
| DeepSeek Harness | 版本凭证 | 版本对不上 → FS_STALE_VERSION,物理上不给写 | fs-observation-policy 插件 |
| Claude Code | 运行时检查 | 规则写进工具说明书,原文含"this tool will error" | study/chapters/14-all-prompts.md 第 1124 行 |
| Grok Build | 提示语 | 匹配失败时在报错文案里加一句"建议重新读一遍" | search_replace/mod.rs 第 111–113 行 |
CC 的差别在挂载位置:检查长在 FileEditTool 自己身上;DSH 抽成独立插件,read / edit / write / str_replace_editor 四个工具共享同一本账,工具本体零权限代码。
Grok 留下了斗争痕迹:配置里 skip_read_before_edit 字段注释标着"已废弃的运行时空操作",说明先读后写曾是硬开关、后来松了绑。
公平地说:Grok 在编码坑那关备了 unicode_normalized_fallback(第 103–110 行),智能引号、长横线这类肉眼难辨的字符匹配失败时可归一化重试;DSH 的 edit 目前只按行尾规范化后精确匹配。各有取舍。
设计工具输出契约时:
truncated / total)?设计文件编辑防线时:
deepseek-harness-master 本地仓库核对。先把规范值定义清楚,再让模型文本和 UI 卡片都从它投影出来。反过来做,你会得到两份需要人工保证一致的数据——而它们迟早会不一致。
只要它要在回放时重跑一次,任何隐藏输入(时间、磁盘、随机数)都会变成回放的不确定性。把"纯"写进注释,再靠 review 守住它。
换 content 与换 value 二选一,写在类型里是编译器报错,写在文档里是评审时吵架。类型能消灭的问题类别,不要留给运行时。
提示语浓度、运行时检查浓度、版本凭证浓度——选错不是强度差异,是可靠性量级差异。先想清楚这条规则失守一次的代价有多大,再决定用哪一级。
版本检查排在匹配之前,报"陈旧"而非"没找到",模型才能一次性做对动作。设计错误码时问一句:拿到这个错误的模型,会采取正确的下一个动作吗?
接入一个第三方 sql_query 工具,查询返回 1200 行但只保留前 50 行。写出:value 的 schema 大致长什么样;render 给模型的文本要不要包含全部 50 行;presentResult 选六种卡片中的哪一种,截断信息放哪。最后一问:安全插件想对模型隐藏手机号列,该换 content 还是换 value?
rows、total、truncated 三个字段。rows 是保留下来的 50 行,total 是 1200,truncated 为 true。generic 或 search 类卡片。截断信息必须放在卡片字段里(truncated / total),而不是只写在文本里——这样 UI 永远不会把砍过的结果画成完整结果。FS_NOT_OBSERVED,读过但被外部改动 FS_STALE_VERSION;判定只看账本不看运气。来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂课程素材,为二次演绎版本。