第十一讲. 让 agent 的运行过程可观测
Agent 执行任务时常常像一个黑盒:它跑了 20 分钟,改了一堆文件,然后告诉你"做完了但有两个测试失败"。你问它为什么失败,"不太确定,可能是时序问题"。你问它改了哪些关键路径,"让我看看代码……"。
这种情况的根源在 harness 缺乏可观测性。agent 执行任务时,如果看不到运行时的实际状态,就只能凭猜测做决策。
没有可观测性,agent 在不确定状态中做决策,评估变成主观判断,重试变成盲目摸索。 OpenAI 和 Anthropic 都将可靠性定义为证据问题,harness 必须以可指导下一步决策的形式暴露运行时行为和评估信号。
可观测性缺失的影响
当 harness 缺乏可观测性时,四类问题会系统性出现。
无法区分"正确"和"看似正确":一个函数在代码审查时看起来完全正确,语法对、逻辑通。但运行时因为边界条件处理错误,在特定输入下产生了不正确结果。只有运行时追踪能揭示实际执行路径偏离了预期。代码审查看的是"写了什么",运行时追踪看的是"实际跑了什么",两者缺一不可。
评估变成玄学:没有评分标准和验收条件时,评估者(人或 agent)只能依赖隐式假设。同一个输出,不同评估者可能给出截然不同的评价,质量评估不可复现。
重试变成盲猜:agent 不知道为什么失败时,重试方向是随机的。它可能在错误的方向上反复尝试,修复了不相关的代码路径而忽略真正的故障根源。每次盲重试都消耗 token 和时间。
会话交接信息断崖:当未完成的工作移交给下一个会话时,缺乏可观测性意味着新会话必须从零诊断系统状态。Anthropic 的长期运行 agent 观察表明,这种重复诊断可能占会话总时间的 30-50%。
实际运行示例
来看一个使用"计划者-生成者-评估者"三角色工作流的 harness,执行"为应用添加暗色模式"任务。
没有可观测性:计划者输出模糊描述,生成者根据模糊描述实现暗色模式,但和计划者的隐式预期不一致。评估者基于自己的隐式标准拒绝,但说不出具体哪里不对,只有一句"感觉不太对"。生成者基于模糊拒绝理由盲重试,循环 3-4 次,总耗时约 45 分钟,最终勉强产出。
有完整可观测性:计划者输出冲刺合同,列明要改哪些组件、每个组件的验证标准、排除项(不处理打印样式)。生成者按合同实现,运行时可观测性记录每个组件的样式加载和应用过程。评估者用评分标准逐维度评估,附具体证据引用:"按钮颜色对比度不足(WCAG AA 标准 4.5:1,实测 2.1:1)"。一次迭代出高质量结果,总耗时约 15 分钟。
效率差 3 倍,区别只在可观测性。
双层可观测性
可观测性不是"多打点日志"那么简单。它分两层,缺一不可。
flowchart LR
Contract["先把这次任务写清楚<br/>改哪些文件 / 不改哪些部分 / 怎么算通过"] --> Generator["生成器"]
Generator --> Signals["运行时收集<br/>日志 / 追踪 / 健康检查"]
Contract --> Review["按检查表逐项看<br/>功能 / 测试 / 边界"]
Signals --> Review
Review --> Verdict["指出哪一项没过<br/>以及应该去改哪里"]
Verdict --> Generator
运行时可观测性:系统层的信号,包括日志、追踪、进程事件、健康检查,回答"系统做了什么"。
过程可观测性:harness 决策工件的可见性,包括计划、评分标准、验收条件,回答"为什么这个变更应该被接受"。
核心概念
- 运行时可观测性:系统层的信号,包括日志、追踪、进程事件、健康检查,回答"系统做了什么"。
- 过程可观测性:harness 决策工件的可见性,包括计划、评分标准、验收条件,回答"为什么这个变更应该被接受"。
- 任务轨迹:一个任务从开始到完成的完整决策路径记录,类似分布式系统中的请求追踪。agent 的每一步操作及其上下文都被记录,出了问题可以回放完整过程。
- 冲刺合同:编码开始前协商的短期协议,明确任务范围、验证标准、排除项。是过程可观测性的核心工具。
- 评估评分标准:把质量评估从主观判断变成基于证据的结构化评分,使不同评估者对同一输出产生相似结论。
- 双层可观测性:系统层和过程层同时设计、相互增强。运行时信号解释行为,过程工件解释意图。
Agent 自行处理可观测性的局限
你可能在想:"agent 不能自己打日志吗?" 问题在于:
- agent 不知道它不知道什么:它不会主动记录自己没意识到需要的信号。没有 harness 层面的约束,agent 只会记录它认为重要的东西,而它认为重要的东西往往不够。
- 日志格式不统一:不同会话用不同的日志格式,无法做系统化分析。
- 过程可观测性不是日志能解决的:冲刺合同和评分标准是结构化的工件,需要 harness 层面的支持,不是多 print 几行就能搞定的。
搭建可观测性的方法
1. 在 harness 里内置运行时信号采集
不要依赖 agent 自己打日志。harness 应该自动采集以下信号:
- 应用生命周期:启动、就绪、运行、关闭各阶段状态
- 功能路径执行:关键路径的执行记录,包括入口、检查点和出口
- 数据流:数据在组件间的流转记录
- 资源利用:异常的资源使用模式(如内存持续增长)
- 错误和异常:完整的错误上下文,不只是错误消息
2. 实施冲刺合同
在每个任务开始前,生成者和评估者(可能是同一个 agent 的不同调用)协商一份合同,明确这次要做什么、怎么做算通过:
# 冲刺合同: 暗色模式支持
## 范围
- 修改主题切换组件
- 更新全局 CSS 变量
- 添加暗色模式测试
## 验证标准
- 每个组件的视觉回归测试通过
- 主流程端到端测试通过
- 无样式闪烁 (FOUC)
## 排除项
- 不处理打印样式
- 不处理第三方组件暗色模式
3. 建立评估评分标准
把"好不好"变成可量化的评分:
# 评分标准
| 维度 | A | B | C | D |
|------|---|---|---|---|
| 代码正确性 | 所有测试通过 | 主流程通过 | 部分通过 | 编译失败 |
| 架构合规 | 完全合规 | 轻微偏离 | 明显偏离 | 严重违反 |
| 测试覆盖 | 主流程+边缘 | 仅主流程 | 仅有骨架 | 无测试 |
4. 用 OpenTelemetry 标准化
为每个 harness 会话创建一个 trace,每个任务创建一个 span,每个验证步骤创建子 span。使用标准属性标注关键信息。这样可观测性数据可以和标准工具链(Jaeger、Zipkin)集成。
Anthropic 的三 agent 架构实验
Anthropic 在 2026 年 3 月发布了一项系统性的 harness 实验。他们用三种架构跑同一个任务("用 Web Audio API 做一个浏览器端 DAW"),记录了详细的阶段数据:
| Agent 和阶段 | 时长 | 成本 |
|---|---|---|
| Planner(规划者) | 4.7 分钟 | $0.46 |
| Build 第 1 轮 | 2 小时 7 分钟 | $71.08 |
| QA 第 1 轮 | 8.8 分钟 | $3.24 |
| Build 第 2 轮 | 1 小时 2 分钟 | $36.89 |
| QA 第 2 轮 | 6.8 分钟 | $3.09 |
| Build 第 3 轮 | 10.9 分钟 | $5.88 |
| QA 第 3 轮 | 9.6 分钟 | $4.06 |
| 总计 | 3 小时 50 分钟 | $124.70 |
三个 agent 各司其职,每个都有明确的可观测性角色:
Planner(规划者):接收一段 1-4 句话的用户需求,扩展成完整产品规格。被要求"大胆设定范围"并且"专注于产品上下文和高层技术设计,而不深入详细的技术实现"。原因是:如果 planner 过早指定了粒度技术细节且搞错了,错误会级联到下游实现。更好的做法是约束交付物,让 agent 在执行中自己找到路径。
Generator(生成者):按 sprint 逐个功能实现。每个 sprint 前和 evaluator 协商一份 sprint 合同,约定这个功能块"做完"的标准。然后按合同实现,自评后交给 QA。
Evaluator(评估者):用 Playwright MCP 像用户一样点击运行中的应用,测试 UI 功能、API 端点和数据库状态。对每个 sprint 按四个维度评分:产品深度、功能性、视觉设计和代码质量。每个维度有硬性阈值,任一不达标则 sprint 失败,generator 收到详细反馈后修复。
QA 第 1 轮反馈的示例:"这是一个视觉上令人印象深刻的应用,AI 集成工作良好,但核心 DAW 功能有几个是展示性的,没有交互深度:剪辑不能拖拽/移动,没有乐器 UI 面板(合成器旋钮、鼓垫),没有视觉效果编辑器(EQ 曲线、压缩器仪表)"。这些不是边缘情况,它们是让 DAW 可用的核心交互。具体的、有证据的反馈,不是"感觉不对"。
Evaluator 不是一开始就这么强。早期版本会识别出合理的问题,然后说服自己这些问题不严重,最终批准工作。调校方式是:读 evaluator 的日志,找到它的判断和人类判断分叉的地方,更新 QA 的 prompt 解决那些问题。经过几轮这种开发循环,evaluator 的评分才变得合理。
来源:Anthropic: Harness design for long-running application development
核心要点
- 可观测性是 harness 的架构属性:它是设计时必须考虑的核心能力,不应只作为事后添加的功能。
- 双层可观测性缺一不可:运行时信号解释"发生了什么",过程工件解释"为什么这样做"。
- 冲刺合同前置对齐工作:防止"生成者做了评估者因可预见原因立即拒绝的东西"。
- 评分标准让评估可复现:不同评估者对同一输出产生相似评分。
- 可观测性缺失导致 30-50% 的会话时间浪费在重复诊断上。
延伸阅读
- Observability Engineering - Charity Majors — 现代可观测性工程的理论和实践框架
- Dapper - Google (Sigelman et al.) — 大规模分布式追踪的开创性实践
- Harness Design - Anthropic — 引入冲刺合同和评估评分标准
- Site Reliability Engineering - Google — 可观测性在生产系统中的系统化应用
练习
- 可观测性差距分析:审查你当前的 harness,评估系统层和过程层可观测性。找出无法从现有信号区分的系统状态,提出补充方案。
- 冲刺合同实践:为一个真实任务写冲刺合同。让 agent 按合同执行,对比没有合同时的效率和质量差异。
- 任务轨迹构建:记录一个完整编码任务中 agent 的每一步操作。用 OpenTelemetry 语义约定标注。分析轨迹中的信息瓶颈——哪些步骤的决策缺乏足够的信号支持。
代码示例
评估者评分标准示例
评估者评分标准示例
对每个维度使用 1-5 评分:
- 基础性:答案是否清晰关联到导入的来源?
- 引用质量:来源引用是否可见且具体?
- 功能性:用户能否完成问答流程?
- 产品一致性:工作流是否感觉一体化?
runtime-logger.ts
/**
* runtime-logger.ts
*
* A structured logging module demo. Shows ad-hoc console.log output vs
* structured JSON log output when diagnosing a failure. Includes a seeded
* failure scenario and demonstrates how structured logs pinpoint the issue
* faster.
*
* Run: npx tsx docs/lectures/lecture-11-why-observability-belongs-inside-the-harness/code/runtime-logger.ts
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface StructuredLogEntry {
timestamp: string;
level: "info" | "warn" | "error" | "debug";
component: string;
action: string;
durationMs?: number;
input?: unknown;
output?: unknown;
error?: string;
correlationId: string;
}
// ---------------------------------------------------------------------------
// Simulated pipeline with a seeded failure
// ---------------------------------------------------------------------------
const CORRELATION_ID = "req-" + Math.random().toString(36).slice(2, 8);
// Simulated stages of a document Q&A pipeline
interface PipelineStage {
component: string;
action: string;
durationMs: number;
success: boolean;
errorMessage?: string;
input?: unknown;
output?: unknown;
}
function runPipeline(): PipelineStage[] {
return [
{
component: "DocumentLoader",
action: "parse_upload",
durationMs: 45,
success: true,
input: { filename: "report.pdf", size: "2.3MB" },
output: { chunks: 47 },
},
{
component: "ChunkIndexer",
action: "embed_and_store",
durationMs: 230,
success: true,
input: { chunks: 47 },
output: { indexed: 47, embeddingDim: 1536 },
},
{
component: "QueryRouter",
action: "route_query",
durationMs: 12,
success: true,
input: { query: "What is the revenue target?" },
output: { routedTo: "RetrievalEngine" },
},
{
// SEEDED FAILURE: Retrieval returns 0 results due to dimension mismatch
component: "RetrievalEngine",
action: "semantic_search",
durationMs: 180,
success: false,
errorMessage: "Vector dimension mismatch: query embedding dim=768, index embedding dim=1536",
input: { query: "What is the revenue target?", topK: 5 },
output: { results: 0 },
},
{
component: "AnswerGenerator",
action: "generate_with_citations",
durationMs: 1500,
success: true, // Doesn't crash, but produces a bad answer
input: { context: [], query: "What is the revenue target?" },
output: { answer: "I could not find relevant information.", citations: 0 },
},
];
}
// ---------------------------------------------------------------------------
// Ad-hoc logging (console.log style)
// ---------------------------------------------------------------------------
function printAdHocLog(stages: PipelineStage[]): void {
console.log("Starting document Q&A pipeline...");
console.log("User uploaded report.pdf");
for (const stage of stages) {
if (stage.success) {
console.log(`${stage.component}: ${stage.action} done (${stage.durationMs}ms)`);
} else {
console.log(`${stage.component}: something went wrong`);
}
}
console.log("Pipeline finished. Answer: I could not find relevant information.");
}
// ---------------------------------------------------------------------------
// Structured logging (JSON)
// ---------------------------------------------------------------------------
function printStructuredLog(stages: PipelineStage[]): StructuredLogEntry[] {
const entries: StructuredLogEntry[] = [];
for (const stage of stages) {
entries.push({
timestamp: new Date().toISOString(),
level: stage.success ? "info" : "error",
component: stage.component,
action: stage.action,
durationMs: stage.durationMs,
input: stage.input,
output: stage.output,
error: stage.errorMessage,
correlationId: CORRELATION_ID,
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Diagnose failure from structured logs
// ---------------------------------------------------------------------------
function diagnoseFromStructured(entries: StructuredLogEntry[]): string[] {
const diagnosis: string[] = [];
// Find errors
const errors = entries.filter((e) => e.level === "error");
if (errors.length > 0) {
diagnosis.push("ERRORS FOUND:");
for (const err of errors) {
diagnosis.push(` - ${err.component}.${err.action}: ${err.error}`);
}
}
// Check for latency spikes
const slowStages = entries.filter((e) => (e.durationMs ?? 0) > 1000);
if (slowStages.length > 0) {
diagnosis.push("LATENCY SPIKES:");
for (const s of slowStages) {
diagnosis.push(` - ${s.component}.${s.action}: ${s.durationMs}ms`);
}
}
// Check for cascading failures
const emptyOutputs = entries.filter((e) => {
const out = e.output as Record<string, unknown> | undefined;
return out && typeof out === "object" && "results" in out && (out.results as number) === 0;
});
if (emptyOutputs.length > 0) {
diagnosis.push("EMPTY OUTPUTS (possible cascade):");
for (const e of emptyOutputs) {
diagnosis.push(` - ${e.component}.${e.action}: returned 0 results`);
}
}
return diagnosis;
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
function pad(s: string, len: number): string {
return s.length >= len ? s : s + " ".repeat(len - s.length);
}
function run(): void {
const pipeline = runPipeline();
console.log("\n" + "=".repeat(90));
console.log(" OBSERVABILITY DEMO -- Ad-hoc vs Structured Logging");
console.log("=".repeat(90));
// --- Ad-hoc output ---
console.log("\n" + "-".repeat(90));
console.log(" SCENARIO A: Ad-hoc console.log output");
console.log("-".repeat(90) + "\n");
printAdHocLog(pipeline);
console.log("\n Diagnosis from ad-hoc logs: ??? Hard to tell what went wrong.");
console.log(" The failure message is vague: 'something went wrong'.");
console.log(" No dimensions, no input/output data, no correlation ID.");
// --- Structured output ---
console.log("\n" + "-".repeat(90));
console.log(" SCENARIO B: Structured JSON log output");
console.log("-".repeat(90) + "\n");
const structuredEntries = printStructuredLog(pipeline);
const header = `| ${pad("Timestamp", 26)}| ${pad("Level", 6)}| ${pad("Component", 20)}| ${pad("Action", 25)}| ${pad("Duration", 9)}| Error?`;
const sep = `|${"-".repeat(28)}|${"-".repeat(8)}|${"-".repeat(22)}|${"-".repeat(27)}|${"-".repeat(11)}|${"-".repeat(30)}`;
console.log(header);
console.log(sep);
for (const entry of structuredEntries) {
const hasError = entry.error ? entry.error.slice(0, 30) : "";
const marker = entry.level === "error" ? ">>" : " ";
console.log(
`${marker}| ${pad(entry.timestamp, 26)}| ${pad(entry.level, 6)}| ${pad(entry.component, 20)}| ${pad(entry.action, 25)}| ${pad(String(entry.durationMs) + "ms", 9)}| ${hasError}`
);
}
// --- Diagnosis ---
console.log("\n" + "-".repeat(90));
console.log(" AUTOMATED DIAGNOSIS FROM STRUCTURED LOGS");
console.log("-".repeat(90) + "\n");
const errors = structuredEntries.filter((e) => e.level === "error");
for (const err of errors) {
console.log(` ROOT CAUSE: ${err.component}.${err.action}`);
console.log(` Error: ${err.error}`);
console.log(` Input: ${JSON.stringify(err.input)}`);
console.log(` Output: ${JSON.stringify(err.output)}`);
console.log(` Correlation ID: ${err.correlationId}`);
}
// Downstream impact
console.log("\n DOWNSTREAM IMPACT:");
const answerGen = structuredEntries.find((e) => e.component === "AnswerGenerator");
if (answerGen) {
const out = answerGen.output as Record<string, unknown>;
console.log(` AnswerGenerator received empty context (${JSON.stringify(answerGen.input)})`);
console.log(` Produced answer: "${out.answer}" with ${out.citations} citations`);
}
// Comparison summary
console.log("\n" + "=".repeat(90));
console.log(" COMPARISON");
console.log("=".repeat(90) + "\n");
const cHeader = `| ${pad("Metric", 35)}| ${pad("Ad-hoc Logs", 18)}| ${pad("Structured Logs", 18)}|`;
const cSep = `|${"-".repeat(37)}|${"-".repeat(20)}|${"-".repeat(20)}|`;
console.log(cHeader);
console.log(cSep);
console.log(`| ${pad("Root cause identifiable", 35)}| ${pad("No", 18)}| ${pad("Yes", 18)}|`);
console.log(`| ${pad("Input/output traceable", 35)}| ${pad("No", 18)}| ${pad("Yes", 18)}|`);
console.log(`| ${pad("Correlation across steps", 35)}| ${pad("No", 18)}| ${pad("Yes", 18)}|`);
console.log(`| ${pad("Machine-parseable", 35)}| ${pad("No", 18)}| ${pad("Yes", 18)}|`);
console.log(`| ${pad("Time to diagnose", 35)}| ${pad("Minutes (manual)", 18)}| ${pad("Seconds (auto)", 18)}|`);
console.log("\n Structured logging transforms debugging from guesswork into a deterministic lookup.\n");
}
run();
Sprint 契约示例
Sprint 契约示例
Sprint 目标:
- 为有据可依的问答结果添加可见引用
完成意味着:
- 用户提出一个问题
- 应用返回一个答案
- 显示至少一个引用
- 点击引用在文档视图中打开来源位置