AI Agent 第四章:沉默不是正常,给编码 Agent 补上反馈回路

最后更新:2026-08-07
所属系列:AI Agent 实战 · 第 4 / 4 篇
文章目录 21 个章节

第三章把工作交给了多个模型和子代理。真正长期用下来,遇到的第一个问题不在模型质量上:任务派出去了,终端上什么都没变化。你不知道它还在跑、已经跑完,还是十分钟前就死了。

编码 Agent 的可观测性缺陷有一个共同的形状:故障的表现是「什么都不显示」,而一切正常的表现也是「什么都不显示」。 两者不可区分时,用户学会的行为是忽略界面,改成自己定期切回终端看一眼。工具于是白白多了一个人工轮询环节。

这一章记录三个具体案例——回合结束没人通知、子代理在跑但看不出几个、最贵的故障最安静——给出每个案例的实现与取舍,最后抽出可复用的规则。代码取自我为 Pi 写的扩展包 pi-extends,但结论不依赖某个具体宿主。

案例一:回合结束,没有任何通知

终端程序通知用户的传统手段是 OSC 转义序列,写到 stdout 即可,由终端模拟器转成系统通知:

OSC 9    ESC ] 9 ; <消息> BEL          # iTerm2 / Windows Terminal / Kitty
OSC 777  ESC ] 777 ; notify ; 标题 ; 正文   # rxvt 系及部分终端
OSC 99   ESC ] 99 ; <元数据> ; 正文     # Kitty 的通知协议,支持图标与按钮
BEL      \a                             # 最原始的响铃

pi 一条都不发,也不响铃。派一个跑十分钟的子代理出去,只能自己回来看有没有停。

补法很直接:fork 系统通知命令。

export function notifyCommand(req: NotifyRequest, platform = process.platform) {
	const title = sanitizeLine(req.title, MAX_TITLE) || "pi";
	const body = sanitizeLine(req.body, MAX_BODY);
	if (platform === "darwin") {
		const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
		return {
			command: "osascript",
			args: ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
		};
	}
	if (platform === "linux" || platform === "freebsd" || platform === "openbsd") {
		return {
			command: "notify-send",
			args: ["--app-name=pi", `--urgency=${req.urgency ?? "normal"}`, "--", title, body],
		};
	}
	return null; // 其他平台:静默跳过,不报错
}

十几行代码。真正的设计在下面四个取舍上。

默认关闭

装完就有副作用的功能必须由用户显式打开。SSH 会话和容器里没有通知守护进程,开着只会每轮白跑一个必然失败的子进程。这和「旁审」那类功能是同一个取舍:一个功能只要会主动向外做点什么,默认值就该是关。

不可信正文永不进 shell

通知正文来自模型输出和文件路径,是不可信数据。这里有两层防护。

第一层是不经 shell。宿主提供的 pi.exec(command, args[]) 收的是参数数组,正文里的引号、反引号、$(...) 不可能变成命令。如果用 exec("notify-send '" + body + "'") 这种拼字符串的写法,一个让模型输出反引号的任务就变成了任意命令执行。

第二层是清洗与截断。控制字符会让 notify-send 的参数解析和 osascript 的字符串字面量同时出问题:

const ANSI_CSI = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
const ANSI_OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g;
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;

export function sanitizeLine(text: string, max: number): string {
	const flat = text
		.replace(ANSI_OSC, "")
		.replace(ANSI_CSI, "")
		.replace(CONTROL_CHARS, " ")
		.replace(/\s+/g, " ")
		.trim();
	return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}…`;
}

还有一个容易漏的细节:notify-send 的参数列表里要加 -- 终止选项解析。通知正文完全可能以 - 开头(比如一个 flag 名或一行 diff),不加 -- 就会被当成未知选项。

计时挂在哪个事件上

这是最容易选错的一处。宿主通常有一组回合生命周期事件,名字都很像:

agent_start → agent_settled    一次回合恰好一对
agent_start → agent_end        自动重试与上下文压缩之间会触发多次

按 agent_end 计时,一次十分钟的任务会弹四条通知,每条都说「2 分钟」——因为它把一次长任务切成了几段。选事件的判据不是名字像不像,是它在一次逻辑回合里是否恰好触发一次。

pi.on("agent_start", (_event, ctx) => {
	startedAt = Date.now();
});

pi.on("agent_settled", async (_event, ctx) => {
	const began = startedAt;
	startedAt = undefined;
	if (began === undefined) return;
	const elapsed = Date.now() - began;
	const config = getConfig(ctx.cwd, ctx.isProjectTrusted());
	if (!shouldNotifyIdle(elapsed, config.notifications)) return;
	await sendNotification(pi, {
		title: `pi · ${path.basename(ctx.cwd) || ctx.cwd}`,
		body: `回合完成 · ${formatDuration(elapsed)}`,
	});
});

时长门槛(默认 20 秒)的含义是「用户大概已经走开了」。三秒就答完的问题不配打断人。

命令不存在只探测一次

没装 notify-send 的机器上,每轮失败一次子进程纯属浪费。第一次 ENOENT 或 exit 127 之后置位一个模块级 latch,之后不再 fork:

let notifyUnavailable = false;

export async function sendNotification(pi: ExtensionAPI, req: NotifyRequest): Promise<void> {
	if (notifyUnavailable) return;
	const cmd = notifyCommand(req);
	if (!cmd) {
		notifyUnavailable = true;
		return;
	}
	try {
		const r = await pi.exec(cmd.command, cmd.args, { timeout: NOTIFY_TIMEOUT_MS });
		if (r.code === 127) notifyUnavailable = true;
	} catch (err) {
		const msg = err instanceof Error ? err.message : String(err);
		if (msg.includes("ENOENT") || msg.includes("not found")) notifyUnavailable = true;
	}
}

注意这个函数永不抛错。通知失败不该影响任何一次回合或子代理——反馈通道的故障不能反过来变成主流程的故障。

通道自己要能自检

这个功能最典型的失败不是崩溃,是「我打开了,但没反应」。而排查它需要知道三件事:当前平台探测到哪个命令、这个命令在不在、守护进程收不收。

所以控制台里给它一整页而不是一个开关:页面上直接写出探测到的命令名,并放一个「发一条试试」当场验证通道。凡是依赖外部环境才能生效的功能,都应该带一个「现在就试一次」的入口。 否则用户唯一的排查手段是重启并祈祷。

案例二:子代理在跑,界面上看不出几个

并行派三个子代理出去,状态栏没有任何变化,直到全部结束、结果一次性刷出来。中间那几分钟无从判断是否卡死。

第一个障碍不是渲染,是状态怎么跨模块共享。状态栏和 subagent 工具是两条互不认识的注册链:

registerFooter(pi)      拿到自己的 ctx,负责渲染
subagentTool.execute()  拿到另一个 ctx,负责跑子进程

两边都没有对方的引用。这类情况下模块级单例是正确答案,不是偷懒——它就是这个进程内的一块共享状态,写成单例比在注册链之间层层传参更诚实。

第二个决定:用宿主的状态槽,不要自己画第二份状态栏。宿主提供 ctx.ui.setStatus(key, text) 这类扩展状态槽时,写进去就好,宿主内部已经跟着一次重绘。好处是用户换回宿主自带的状态栏,这一段照样显示,不必维护两份渲染代码。

第三个决定:配对增减,而不是存一次批次快照。模型在一轮里可以发起两次 subagent 调用,第二次不该把第一次的进度覆盖掉:

export function markAgentStart(): void {
	state.running++;
	state.launched++;
}

export function markAgentEnd(ok: boolean): void {
	// 不让 running 掉到负数:start/end 配对由调用方的 try/finally 保证,
	// 但热重载或异常路径下宁可少减一次也不要显示 -1。
	state.running = Math.max(0, state.running - 1);
	if (ok) state.done++;
	else state.failed++;
}

第四个决定:清空点选在哪。跑完那一刻清,还是下一轮开始时清?选后者。回答刚出来的那几秒,正是想看「几个成功几个失败」的时候:

export function formatAgentStatus(s: Readonly<AgentStatus> = state): string | undefined {
	if (s.launched === 0) return undefined; // 没派过就不该多出一个空槽
	if (s.running > 0) return `${icon("pending")} ${s.running}/${s.launched} 子代理`;
	if (s.failed > 0) return `${icon("cross")} ${s.failed}/${s.launched} 子代理失败`;
	return `${icon("check")} ${s.done} 子代理`;
}

launched === 0 时返回 undefined 而不是空字符串:状态栏是稀缺空间,没有信息时应该整段消失,而不是留一个占位。

案例三:最贵的故障最安静

这一条是三个里最值钱的。

状态栏里有一段缓存命中率 CH。原实现只在 cacheRead 或 cacheWrite 非零时才画——看着很合理,没有缓存数据就不显示。

问题是「没有缓存数据」有两种完全不同的原因:

会话刚开始,还没有可复用的前缀      → 不显示是对的
这条链路根本丢掉了 cache_control     → 不显示是灾难

后者的代价来自一个容易低估的算术。没有提示缓存时,第 N 轮要把前 N−1 轮的全部内容按原价重发一次,累计输入量是轮数的平方级。会话越长,每一轮越贵,而单看某一轮的 token 数完全正常。

一手数据。某条 OpenAI/Anthropic 兼容中继在响应里既没有 cache_read_input_tokens 也没有 cache_creation_input_tokens,试过带 anthropic-beta: prompt-caching 头和完整的客户端头,都没有打开。一次 50 分钟、146 次模型调用的会话累计输入 21.25M token。把同一段会话按不同上下文窗口回放(窗口越小自动压缩越早触发):

上下文窗口   累计输入 token   自动压缩次数   相对 1M
1M              21.25M            0           100%
200k            14.29M            1            67%
150k            11.99M            2            56%
120k             9.19M            2            43%

窗口开得越大越贵。这与直觉相反——直觉是「窗口大就少压缩、少丢信息」。在没有缓存的链路上,大窗口意味着每轮重发的基数更大。(当然也不是越小越好:压得太狠会丢细节导致返工,返工比压缩更贵。这里最后取的是 200k。)

而这整件事在界面上的表现是:状态栏那一段什么都没有。

改法是让零命中也显示,并标红:

export function formatCacheHit(t: {
	input: number;
	turns: number;
	cacheRead: number;
	cacheWrite: number;
	lastCacheHit?: number;
}): CacheHitView | undefined {
	if (t.cacheRead > 0 || t.cacheWrite > 0) {
		return t.lastCacheHit == null ? undefined : { text: `CH${t.lastCacheHit.toFixed(1)}%`, warn: false };
	}
	if (t.turns >= NO_CACHE_WARN_TURNS && t.input >= NO_CACHE_WARN_INPUT) {
		return { text: "CH0%", warn: true };
	}
	return undefined;
}

两个阈值必须同时成立:至少 3 轮,且累计输入超过 20 万 token。只看 token 会误报——一次粘贴一个大文件就能单独超过 token 阈值,那不是链路故障。只看轮数会漏报小会话,但小会话本来也不值得打断。

告警整个会话只弹一次。这是通道属性,不是这一轮的问题;每轮弹一次会被当成噪音直接划过去,等于没有告警。

从三个案例抽出的规则

沉默不是成功

如果一个状态在故障时和正常时都是空白,那它就不是状态。判据可以写成一句自问:如果这个进程现在崩了,我的界面上会出现任何东西吗? 答案是「不会」时,就该加一个显式的失败态。

告警要覆盖所有终止状态

只匹配成功标记的监控,在崩溃、挂起、被杀死时全都保持沉默——而沉默看起来和「还在跑」一模一样。宁可多一点噪音,也不要让崩溃循环无声无息。

有副作用的功能默认关闭

会向外发东西、会拉起子进程、会写文件的功能,默认值都该是关。用户显式打开的功能,出问题时他知道该去哪里找;装完就自动生效的功能,出问题时是一桩悬案。

不可信正文只走参数数组

模型输出、文件路径、错误信息,全都是不可信数据。传给外部命令时只用参数数组,先剥 ANSI 与控制字符再截断,选项解析处加 --。这条与 Agent 无关,是老规矩,只是 Agent 让不可信文本的来源变多了。

状态和计时挂在幂等的事件上

宿主的生命周期事件里,名字最像的那个往往不是触发次数正确的那个。挑事件之前先问:一次逻辑回合里它触发几次。

反馈通道自己要可自检

依赖外部环境的功能,界面上要能看到探测结果,并且能当场发一条测试。

跨注册链的共享状态用宿主槽位

多个注册点需要同一份状态时,用模块级单例持有数据,用宿主提供的槽位输出。自己再画一套渲染,就得永远维护两份。

为什么这类缺陷活得特别久

同一个扩展包在不同阶段撞见过三个 fail-silent 缺陷,形状完全一样:

  1. 配置文件的 $schema 指不到文件。 值写死成 ./schemas/pi-extends.schema.json,而配置文件落在 .pi/ 目录下,编辑器按配置文件自身目录解析,找的是 .pi/schemas/...——任何安装方式下都不存在。指不到时编辑器不报错,只是静默不校验。于是后来新加的字段谁也没校验过。三处代码里的路径写法各不相同,这本身就说明没人验证过这个字符串。
  2. 一个颜色 token 的对比度只有 1.30:1。 分隔点、进度条空槽、翻页箭头、空勾选框全用它上色,实测对各主题自己的背景是 1.30–2.48:1,等于画了跟没画一样。改用另一个 token(4.29–8.63:1)之后,还加了一条源码级断言禁止再用它上色。
  3. 本章案例三的缓存命中段。

共同点:没有报错、没有崩溃、没有测试失败。只有「应该出现的东西没有出现」,而没人知道它应该出现。 这类缺陷不会有人报 bug,因为报 bug 需要先知道期望值。

对策不是更仔细地看,是把期望值写成断言:

$schema 的值从写盘目录能解析到一个真实文件       → 一条测试
源码里不允许再用那个低对比度 token 上色           → 一条源码级测试
多轮零命中时缓存段必须出现且标红                  → 一条测试

第三条的写法值得单独说一句:

// 这是最贵的那种故障:不显示等于「一切正常」,所以零命中必须显示出来。
test("零命中的告警优先于「没有缓存段就不显示」的老行为", () => {
	const quiet = formatCacheHit({ ...base, input: NO_CACHE_WARN_INPUT, turns: NO_CACHE_WARN_TURNS });
	assert.notEqual(quiet, undefined);
});

它测的不是新功能本身,而是「旧行为不会把新告警吃掉」。可观测性的测试里,这种反向断言往往比正向断言更有价值。

怎么验证反馈通道本身

事件驱动的扩展没有可直接调用的纯函数:行为全在回调里,状态是模块级变量。分两层处理。

第一层,把可判定的部分挤成纯函数。 sanitizeLine、notifyCommand、shouldNotifyIdle、formatAgentStatus、formatCacheHit 都不碰 I/O,可以直接断言——验证 macOS 分支的转义、验证 Linux 分支的参数是数组而不是字符串、验证未知平台返回 null,全都不需要真的弹一条通知。这不是为了测试而拆函数,而是「决定发什么」和「真的发出去」本来就是两件事。

第二层,剩下的走假宿主。 把注册进来的命令、事件、工具收进 Map,由用例主动 emit,断言对着「状态槽收到了什么、会话里落了什么」写。假宿主有两处必须与真实实现对齐,否则测出来的不作数:持久化接口要深拷贝数据(真实实现序列化成 JSONL,存的是快照),发消息时要同时补一条对应的会话条目(恢复逻辑靠它定位)。

第三层,变异验证。 测试绿了不等于测到了东西。把阈值从 >= 改成 >、把「取当前分支」换回「取全部条目」,对应用例都必须失败。做不到就说明那条用例在空转。

不使用 pi 也能复用

  • 任何终端 Agent:回合结束通知都可以用同一套(探测命令 → 参数数组 → 清洗截断 → 时长门槛 → 一次性 latch)。如果目标终端支持 OSC 9 或 OSC 99,直接写转义序列更轻,代价是换个终端就没了。
  • 有状态槽的宿主:并行任务计数写进宿主槽位,别自己画第二份状态栏。
  • 没有扩展机制的工具:退一步,用 wrapper 脚本包住非交互调用,退出时发通知,同样能覆盖「派出去十分钟」这个主要场景。
  • 任何按 token 计费的链路:上线前先确认响应里到底有没有缓存字段,并把「零命中」做成显式告警而不是空白。这一条与 Agent 框架无关,但省下的钱最多。

本章小结

沉默不是正常
告警覆盖所有终止状态,不只是成功路径
有副作用的功能默认关闭
不可信正文只走参数数组
计时与状态挂在幂等事件上
反馈通道自己要能自检
期望值写成断言,否则 fail-silent 的缺陷不会有人报

第三章解决的是「谁来干活」,这一章解决的是「你怎么知道它在干活」。两者缺一,多 Agent 系统在真实使用中就会退化成盲发任务加人工轮询——模型能力再强也补不上这个缺口。

下一章回到编排层:用 Go 实现一个带依赖图、并发限制、预算和检查点的任务调度器,把第三章的角色协议变成可运行代码。届时这一章的状态槽与通知,正好是它的输出面板。

参考资料