绕路 18 次,上报 0 次:自改进信号不该指望干活的 agent 自觉

题记:要 agent 一边干活一边记下「哪里不顺」,就像要一个正在抢修的工程师顺手填工单。工单没人填,这不是态度问题,是这件事交错了人。
1. 一条设计文档里的数⌗
MaxClaw 是个会自我改进的 agent 框架:框架让任务变得比应有的更难(工具要试好几次、结果误导、不得不绕路、缺能力),这件事会被记成一条 friction 信号;用户对 agent 行为本身的明确评价(「别这样」「这样不对,因为……」)会被记成 max-feedback 信号。每天的分诊把这些信号聚类、找共同根因、再修。
采集信号原本靠 agent 自己报。身份 prompt 里有一行,原话是鼓励它「随口 report_friction 抱怨一句……小事也说,说完照常把任务做完」;用户评价则靠 agent 主动调 improve_feedback。
这次改动的设计文档 docs/shadow-signals.md 开头记了一个数:
| 观察 | 数 |
|---|---|
| 统计过的任务里,agent 绕过障碍的次数 | 18 |
| 其中 agent 主动说清楚的次数 | 0 |
用户在对话里直接评价 agent 行为后,agent 调 improve_feedback 的次数 | 0(文档原话:「一次都没用」) |
prompt 写得再恳切,采集率也是零。这不难理解。
2. 为什么自报一定漏⌗
自报要过两道筛子,每一道都和 agent 当时的状态挂钩:
真实发生的摩擦
│
▼ 筛子一:注意力 —— agent 正在解决问题,目标是「做完」,不是「记录」
│ 停下来调一个和任务无关的工具 = 打断自己
▼ 筛子二:自我评价 —— 绕路成功了,在它眼里这不是问题,是「解决了」
│ 用户说「别这样」,它的本能是改正行为,而不是上报
▼
被记下来的信号 ≈ 0
第二道筛子更要命。摩擦信号最有价值的恰恰是那些被 agent 自己消化掉的:它绕过去了,任务完成了,从它的角度看一切正常。让当事人判断「刚才算不算框架的问题」,等于让最没有动机说「算」的那一方来判。用户反馈也一样:agent 听到批评后的正确反应是道歉、改做法、继续,「另起一步把这句话归档到改进系统」不在它此刻的目标里。
所以问题不在 prompt 写得不够强,而在采集这件事交给了一个注意力和立场都不对的人。
3. 换一个旁观者:影子线程⌗
MaxClaw 已经有一个现成的旁观者。每个线程空闲下来后,框架会跑一次「影子回合」:缓存还热时,沿着这条线程最后一次真实请求的前缀逐字不变地接一问,用同一个模型让它提交值得记住的长期事实(冷了就换轻量模型读压缩稿);在 side chat 里,同一次调用还顺带交一份进度报告。这个机制前面的文章写过,这里只关心一点:影子回合看得到完整的对话记录,工具结果、重试、绕路全在里面,而且它此刻没有任务要做。
这次改动只是给那一次调用的工具 shadow_memory_propose 多加两个可选字段:
shadow_memory_propose({
proposals: [...], // 原有:长期记忆提案
progress: {...}, // 原有:side chat 的进度报告
frictions: [ // 新增
{ symptom, tool?, component?, evidence: [消息 id, ...] }
],
feedback: [ // 新增,只在用户本人的对话里要求
{ summary, messageId }
]
})
指令只追加在最后那条 user 消息里(signalsSection,接在 progress 段之后),所以热路径的缓存前缀一个字节都不变。测试 hot path: same products from the same call… 断言了三件事:前 n 条消息的 JSON.stringify 与真实回合发出的完全相同,tools 也完全相同,而冻结前缀里根本不含 frictions 这个词,它只出现在新问题里。prefix.wire.test.ts 在真实的 Anthropic 线协议上把工具 schema 的属性钉成 ['feedback', 'frictions', 'progress', 'proposals']。
没有新增模型调用。 信号搭的是记忆提取本来就要发的那一次请求。
指令本身写得很克制,摘几条关键的:
| 要求 | 原因 |
|---|---|
| 网站挂了、文件不存在、代码自己的 bug 不算摩擦 | 摩擦是「框架让事情更难」,不是「任务本身失败」 |
symptom 一句中立描述,不写怎么修,不含用户原话和私人内容 | 沿用 report_friction 的抱怨契约:抱怨是证据,不是工作项 |
同一个时刻只写一条;行里已有 [调用 report_friction …] 的不重复写 | 和 agent 自报并存时不重复计数 |
| 反馈只收用户对 agent 自身行为的明确评价;对任务内容的评价、普通要求、单纯提问都不算 | 防止把「帮我改成蓝色」当成对 agent 的批评 |
4. 写入口不变,来源打标⌗
新信号不走旁路。摩擦走 recordFailureReport,和 agent 调 report_friction 是同一个函数:落 failure_reports,同一事务里写 friction 信号,指纹 friction:<工具或组件>;反馈走 recordExplicitFeedback,和 improve_feedback 同一个函数,指纹 max-feedback:<线程>:<messageId>。下游的分诊、聚类、修复完全不需要知道信号是谁写的。
区别只在 evidence 里多一个来源标记:origin: 'shadow'、shadowRunId,摩擦还带 evidenceMessageIds。想事后评估「影子读出来的信号和 agent 自报的哪个更准」,按这个字段一分就行。
有个细节值得说。recordFailureReport 原本假设调用者是一次工具调用,「可信上下文」(回合 id、模型、最近的工具调用)从活着的会话缓冲区里取。影子不在那个回合里,取不到。改动的做法是从证据消息反推上下文:
| 字段 | 来源 |
|---|---|
| 回合 id | 第一条证据消息的 turn_id |
| provider | 该回合 assistant 行的 model |
recentToolCalls | 证据里的工具结果行:调用 id、工具名(回查发起它的 assistant 行)、结局、错误分类 |
relatedCallId | 其中第一条出错的那条 |
| 取不到的 | 按原规矩记进 missing,影子没有 agent_runtime / tool_host,所以 capture 为 partial |
为此 recordFailureReport 的参数放宽成 ToolContext | FailureReportContext,并允许调用者直接传 recentToolCalls。冷路径测试钉住了这张表:一个虚构的「抓网页返回 403、换了几种方式才取到」场景,写出的报告 capture 是 partial、missing 恰好是 ['agent_runtime', 'tool_host'],recentToolCalls 里是那条 outcome: 'error'、errorClass: 'http_403' 的调用。
这比 agent 自报其实更可信:自报时 agent 写的是它对那一刻的回忆,影子写的是它指着的那几条消息。
5. 证据门:模型说了不算⌗
旁观者也是一个模型,它可能看错,也可能编。所以每一项都要过确定性的门:
摩擦:evidence 至少一条,每一条都必须是本线程的消息(SQL 里 r.channel = threadId),否则整项 invalid。
反馈更严,因为它声称「用户说过」:
- 线程必须是用户本人的对话。
hearsMax(kind)只认main和side;任务线程里没有用户的话,指令里压根没有 feedback 段,就算模型硬交,写入时也直接invalid(this conversation has no words of Max)。 - 引用的消息要过记忆系统同一道
max-stated证据校验:必须是用户本人的人类消息。 - 还必须属于本线程。
测试 only Max's own words are feedback… 构造了五种冒充:assistant 的回答、工具结果、框架事件、别的线程的用户消息等,五项全部 invalid,一条信号都没写,一条认领都没留。
6. 幂等:一条消息只认领一次⌗
影子是按批读未读行的,同一段对话可能被多次触发;agent 也可能已经自己报过。重复计数会让分诊误判频率,所以加了一张认领表:
CREATE TABLE shadow_signal_evidence (
kind text NOT NULL CHECK (kind IN ('friction', 'feedback')),
message_id text NOT NULL,
run_id text NOT NULL REFERENCES shadow_memory_runs (id) ON DELETE CASCADE,
signal_id text,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (kind, message_id)
);
规则:
- 摩擦:任一证据消息已被认领 →
duplicate。 - 反馈:同指纹的信号已存在(包括 agent 自己调
improve_feedback记过的),或消息已被认领 →duplicate。 - 认领和信号写入在同一事务里,不会出现「信号写了、认领没写」的半截状态。
测试 the same evidence message is never written twice… 跑了两轮:第一轮写入,追加一条消息后再触发,两项都是 duplicate,摩擦信号的 occurrences 仍是 1。另一条线程里先让 agent 用 recordExplicitFeedback 记一次,影子再交同一条,结果 duplicate,库里只有那一条且 evidence.origin 是 undefined,即保留的是 agent 自己那条。
这里的取舍是:自报和旁观并存,以先到的为准。agent 自报不被禁止,只是不再被依赖。
7. 坏一项只坏一项⌗
影子一次调用交五样东西(提案、进度、若干摩擦、若干反馈)。模型输出格式出错是常态,不能让一个畸形字段毁掉整次调用。所以:
- 提案在自己的事务里写完,进度报告和信号在其后各自处理;
frictions/feedback每一项单独safeParse、单独事务;- 结局四种:
written/duplicate/invalid(格式错、证据无效)/failed(写入出错,或调用期间线程的封存 / 类别标签变了); - 全部记进
shadow_memory_runs.signal_results:[{part, index, outcome, signalId?, error?}]。
规格测试的输入很恶意:frictions 是 [空 symptom 空证据, 一条正常的, 'not an object'],feedback 是字符串。结果:
frictions:0:invalid
frictions:1:written
frictions:2:invalid
feedback:0:invalid
提案 1 条、进度报告 1 条照常写入,中间那条好的摩擦也写了。另一组里 frictions 是对象、feedback 是数字 7,两部分都 invalid,提案照写。
隐私边界沿用已有规则:隔离对话根本不进影子线程(ready 查询排除),调用次数为 0;有类别 / 封存的对话照常产出,但 recordSignal 只给它们留中立引用。测试断言这类信号标题以 in a sealed thread 结尾,evidence 里搜不到原文片段,与 agent 在这类线程里调 report_friction 的处理一致。
8. 自报工具退为「刻意信号」⌗
旁观者接手例行采集后,三个自报工具的定位都改了:
| 工具 | 之前 | 之后 |
|---|---|---|
report_friction | 小事也随口抱怨一句 | 只在两种情况调用:要等修复(wait_for_fix: true,绕路比等更贵时);或要强调对话记录里看不出来的问题,比如发生在任何工具调用之外的慢,或在动手之前就把它带偏了的文档 |
improve_feedback | 用户评价 agent 时调用 | 「通常不需要」;只在需要信号立刻落库时自己调 |
remember | 用户本轮值得记的话交给记忆管家 | 只在用户明确要求「记一下」「记住这个」时用;其余由记忆管家和影子线程整理 |
身份 prompt 那行改成「由框架从对话记录里识别并记下,你不必停下来报;照常把任务做完,工具结果、重试和绕路都留在对话里就够了」。system-prompt.test.ts 把新措辞的关键短语逐个钉死,并断言身份段指纹因此移动:正在运行的线程会在下一次上下文分段时拿到新文本,缓存一次性失效一次。连「问用户要授权时」的那条提醒也删掉了「缺能力就 report_friction 说一句」,改成「直说缺什么」。
这个划分的逻辑是:能从记录里读出来的,交给读记录的人;只有当事人知道的,才需要当事人开口。 等修复是一个决定,不是一个观察,只能 agent 自己做;「文档在我动手前就误导了我」不会在工具结果里留下痕迹,也只能 agent 自己说。其余的自报都是在让 agent 重复一份已经存在的记录。
9. 账单⌗
设计文档按每次影子调用估算:
| 项 | 量 |
|---|---|
| 输入:最后那条 user 消息多出的指令 | 约 300(estimateTokens)至 550(按 Claude 分词估)token;热路径下是未缓存的尾部 |
| 输出:没有信号时 | 0(字段省略) |
| 输出:每项信号 | 约 60–120 token |
| 工具 schema 变长 | 约 670 字符(≈200 token),在每个对话的缓存前缀里,按读缓存价;部署时前缀变一次,缓存重建一次 |
| 新增模型调用 | 0 |
和「另起一个离线管路批量读日志找问题」比:那条路要为每段对话再付一次冷输入,而且读的是渲染稿;这里读的是同一份热上下文,增量只有几百 token 的尾巴。
10. 没解决的,和换来的⌗
坦白几件事:
- 偏差没有消失,只是换了位置。 影子也是模型,也会漏判、误判。但它的偏差和「干活那一刻的注意力、对自己表现的评价」脱钩了:它没有任务要赶,也不是被评价的那一方。而且它的每一项都被证据门和幂等表兜住,错了是
invalid,不是脏数据。 - 冷路径更弱。 缓存凉了以后,影子换成轻量模型读截断过的渲染稿,工具结果被截短,识别摩擦的能力肯定不如热路径。这是成本换覆盖率,没有免费午餐。
- 记录之外的事仍要靠 agent。 所以
report_friction没删,只是从「义务」退成「刻意信号」。 - 还没有数据。 改动刚落地,
origin: 'shadow'打标就是为了以后能回答「旁观者读出来的信号,采集率和准确率到底比自报好多少」。现在能说的只是:自报那一栏,设计文档里记的是 0。
这件事说穿了很朴素:记录已经在那里了,问题只是谁来读。 让正在干活的 agent 分心去写一份已经存在的记录,采集率自然跟着它的注意力和自尊心一起掉到零;让一个看得到全部记录、手上没活、写入受确定性校验约束的旁观者来读,采集就从「靠自觉」变成了「机制保证」。