离线回放与故障注入
把事件重放、模拟模型流和重新执行分开,用固定夹具复现增量与工具故障。
离线回放与故障注入
线上异常只有偶尔发生,反复请求真实模型会增加成本,也可能换来完全不同的输出。先把需要复现的边界固定下来,可以把“模型这次怎么回答”与“我们的控制逻辑如何处理事件”分开检查。
回放不是重新执行的同义词。本文提供一个只读事件播放器,再用前文的模拟流驱动真实 Agent,分别验证观察逻辑和控制逻辑。
本系列的独立示例位于仓库 columns/coding-agent/examples/debugging-observability/,固定使用 pi-agent-core、pi-ai、pi-schema、pi-telemetry 1.5.0。先在该目录执行 npm install,下文命令也从该目录运行。示例不需要模型密钥,不读取个人配置。
先选正确的复现层
npm run replay 只读取固定 JSONL,得到一次工具失败后会话收束的时间线。播放器没有导入业务工具,更没有把历史 tool_execution_start 当成新命令执行。
import { createReadStream } from "node:fs"
import { readJsonl } from "./jsonl.js"
import { Recorder } from "./recorder.js"
const recorder = new Recorder("lesson-replay")
const input = process.argv[2] || new URL("../fixtures/events.jsonl", import.meta.url)
for await (const event of readJsonl(createReadStream(input))) {
recorder.accept(event)
}
console.log(JSON.stringify(recorder.snapshot(), null, 2))分帧错误会伪装成模型错误
流传输不保证一次 chunk 就是一条记录。UTF-8 字符也可能分散在多个 chunk,Unicode 行分隔符可能属于 JSON 字符串内容。官方协议按 LF 分帧;不能用会把其他 Unicode 分隔符也视为换行的读取方式。
src/jsonl.ts 保留解码状态,只按 LF 拆行,并对行长、输入块和尾部截断做限制:
export async function* readJsonl(
chunks: AsyncIterable<Uint8Array>,
maxLineBytes = 65536,
): AsyncGenerator<unknown> {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 1) {
throw new Error("maxLineBytes 必须是正整数")
}
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
const parse = (line: string) => {
const value: unknown = JSON.parse(line.endsWith("\r") ? line.slice(0, -1) : line)
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("JSONL 记录必须是对象")
}
return value
}
for await (const chunk of chunks) {
if (chunk.byteLength > 1048576) {
throw new Error("单块输入超过 1 MiB,请缩小读取块")
}
pending += decoder.decode(chunk, { stream: true })
let newline = pending.indexOf("\n")
while (newline !== -1) {
const line = pending.slice(0, newline)
if (Buffer.byteLength(line) > maxLineBytes) {
throw new Error("JSONL 单行超过上限")
}
yield parse(line)
pending = pending.slice(newline + 1)
newline = pending.indexOf("\n")
}
if (Buffer.byteLength(pending) > maxLineBytes) {
throw new Error("未完成的 JSONL 行超过上限")
}
}
pending += decoder.decode()
if (pending.length > 0) {
throw new Error("JSONL 尾部缺少 LF,可能被截断")
}
}本例严格要求末行有 LF。其他历史文件若允许最后一行省略换行,应另设明确模式,不要悄悄把截断错误吞掉。遇到非 JSON 调试输出会失败,也有助于发现扩展污染 stdout。
文本更新不应重复叠加快照
SDK 事件可能带累计消息,wire 更新则只带增量。src/projection.ts 只消费 text_delta,忽略累计快照;message_end 到达时使用最终消息替换文本。它只演示文本,不处理 thinking、图片、工具参数或完整会话恢复。
缺少 message_end 时,投影保持 complete: false。这与“消息文本为空”不同:前者意味着没有收到权威完成消息,后者可能是合法的工具调用回合。测试会检查中断、重复快照误用和最终内容修正。
故障注入要保留预期和边界
npm run demo -- --fail-tool 在工具入口注入失败;它验证真实循环仍把失败结果送回下一次模拟请求。JSONL 测试则把中文逐字节切分,制造无效编码、超大记录和尾部截断。
保存夹具时记录来源、版本、捕获层和脱敏方式。手写教学样本应直接标明手写,不能描述成生产事故。若需要模拟首字或 delta 中断,应构造对应流事件;当前模拟 Agent 直接提供最终消息,不冒充验证了远程流式输出。
复现成功的标准是断言观察到的状态、失败位置和副作用次数,不只是播放器正常退出。本例通过 toolExecutions 检查只执行一次工具;事件播放器自身不会增加它。
依据与版本
本地官方快照为 8efd314fa,相关包标记 1.3.0;配套代码另按已发布的 1.5.0 类型和运行行为核验。主分支文档持续变化,不能混作同一版本。