最后一块拼图
前四篇解剖了 agent 的控制流、上下文工程、工具管道、容错恢复。这些机制都有一个共同的问题:出了事怎么知道发生了什么?
一个 agent 在后台跑,模型选了什么工具、历史被压缩过几次、哪条权限规则拦了一个调用------这些在代码里发生,但默认对外不可见。这一篇解剖 MyCodeAgent 的可观测性体系,看它是怎么把内部行为变成可查的记录的。
结论先说
可观测性体系由三层组成:
| 层 | 组件 | 职责 |
|---|---|---|
| 发射层 | RuntimeRunner._emit() |
统一的事件入口,loop 里所有可观测事实从这里出发 |
| 路由层 | CompositeRuntimeEventSink |
把一个事件同时投递给多个 sink,互相隔离 |
| 持久化层 | TraceRuntimeEventSink + TranscriptRuntimeEventSink |
分别写 JSONL 诊断日志和 append-only 事实 transcript |
一个事件发出,两条路同时走,诊断和恢复各取所需。
一、统一发射点:所有事件从一处出发
loop 里每一个"值得记录的事情"都通过同一个方法发出:
python
# runtime/loop.py RuntimeRunner
def _emit(self, event_type: str, payload: dict[str, Any], *, step: int) -> None:
self._emit_runtime_event(
run_id=self._get_transcript_run_id(),
step=step,
event_type=event_type,
payload=payload,
)
_emit 接受三个要素:事件类型 、payload 、步骤编号 ,构造出一个 RuntimeEvent 对象,交给 sink 路由。
loop 里的调用点分散在各处,但入口只有一个。这意味着:添加新的 sink(比如把事件发到 Prometheus)只需要修改 CompositeRuntimeEventSink 的构造,loop 本身不需要改动。
发射点覆盖了 loop 的所有关键时刻:
bash
message → 消息写入历史(user/assistant/tool 三种 role)
state_transition → 状态转移(USER_INPUT / TOOLS_EXECUTED / MODEL_RECOVERY_RETRY ...)
tool_lifecycle → 工具四阶段(requested / started / completed / failed)
checkpoint → 上下文压缩检查点
terminal → loop 终止(completed / max_steps / token_budget ...)
prompt_assembly → 每步的 prompt 各层 fingerprint
tool_schema → 工具 schema 的 hash(检测工具列表变化)
model_output → 模型原始响应(含 token usage)
二、双 sink:诊断与持久化各司其职
python
# runtime/events.py
def create_runtime_event_sink(trace_logger, recorder) -> CompositeRuntimeEventSink:
return CompositeRuntimeEventSink(
(TraceRuntimeEventSink(trace_logger), TranscriptRuntimeEventSink(recorder))
)
CompositeRuntimeEventSink 把同一个事件投递给两个 sink:
python
def emit(self, event: RuntimeEvent) -> None:
for sink in self.sinks:
try:
sink.emit(event)
except Exception as error:
# sink 失败只打 warning,不影响 loop 状态
logger.warning("Runtime event sink failed for %s: %s", event.type, error)
隔离性是关键:Transcript sink 写入失败不会影响 Trace sink,也不会影响 loop 继续运行。可观测性基础设施的故障不能让 agent 停下来。
Trace sink:诊断日志
TraceRuntimeEventSink 把事件翻译成诊断格式转发给 TraceLogger,写入 memory/traces/trace-{session}.jsonl。这是面向开发和调试的日志:
- 每行一个 JSON,带时间戳、session_id、step、事件名、payload
tool_lifecycle事件额外拆出tool_call(调用时)和tool_result(完成时),让 HTML 报告能分别展示- 文件写完后可以生成 HTML 报告,人可直接阅读
Transcript sink:可恢复事实
TranscriptRuntimeEventSink 只处理五类"可恢复事实"事件(message/state_transition/tool_lifecycle/checkpoint/terminal),写入 memory/transcripts/transcript-{session}.jsonl。这是面向崩溃恢复的日志(见第 14 篇),不记录调试细节,只记录能重建状态的最小事实集。
同一个事件发出,两条路各取所需:Trace 拿走所有细节,Transcript 只拿能恢复状态的事实。
三、TraceLogger:JSONL 流式写入
python
# extensions/tracing/logger.py
def log_event(self, event: str, payload: dict[str, Any], step: int = 0) -> None:
event_obj = {
"ts": _utc_now().isoformat(),
"session_id": self.session_id,
"step": step,
"event": event,
"payload": self._sanitizer.sanitize(payload), # 先脱敏
}
self._current_run_events.append(event_obj) # 内存里保留本次运行的事件
self._write_line(event_obj) # 立即落盘
self._update_stats(event, payload, step) # 更新统计计数
def _write_line(self, event_obj: dict[str, Any]) -> None:
with self._lock: # 写锁,防止并发写坏文件
if self._file_handle:
self._file_handle.write(json.dumps(event_obj, ensure_ascii=False) + "\n")
self._file_handle.flush() # 每次写入立即 flush
每次 log_event 都立即 flush------这和 Transcript 的策略一样,目的是缩短崩溃窗口:进程在两次事件之间崩溃,已 flush 的事件不会丢失。
_current_run_events 在内存里保留本次运行的所有事件,用于生成 HTML 报告(finalize() 时消费)。
四、Prompt fingerprint:检测 prompt 漂移
每步开始时,trace_model_request_state() 会计算并发射 prompt 各层的 fingerprint:
python
# runtime/events.py trace_model_request_state()
current = {
"constitution": prompt_assembly.constitution_fingerprint,
"tool_contracts": prompt_assembly.tool_contracts_fingerprint,
"project_rules": prompt_assembly.project_rules_fingerprint,
"runtime_signals": prompt_assembly.runtime_signals_fingerprint,
}
emit("prompt_assembly", {
...
"changed_layers": [key for key, value in current.items()
if previous.get(key) not in (None, value)],
})
changed_layers 列出本步相比上一步发生了变化的层 。比如 Skills 被更新了,tool_contracts 层的 fingerprint 就变了,trace 里会出现 "changed_layers": ["tool_contracts"]。
这解决了一个调试痛点:模型行为在某步突然变化,但看不出为什么。通过比对 fingerprint,可以精确定位是哪一层 prompt 发生了漂移。
工具 schema 也做了同样的处理:
python
fingerprint = hashlib.sha256(
json.dumps(tools_schema, ...).encode()
).hexdigest()
emit("tool_schema", {"fingerprint": fingerprint, "changed": previous != fingerprint})
五、TraceSanitizer:脱敏保证日志可分享
trace 日志里可能混入敏感数据,TraceSanitizer 在每条事件落盘前做一次扫描:
python
# extensions/tracing/sanitizer.py
SENSITIVE_KEYS = {
"api_key", "token", "password", "authorization", "session_id", ...
}
PATTERNS = [
(re.compile(r"sk-[a-zA-Z0-9]{20,}"), "sk-***"), # OpenAI key
(re.compile(r"Bearer\s+[a-zA-Z0-9._+/=-]{20,}"), "Bearer ***"),
]
def _sanitize_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
for key, value in data.items():
if key.lower() in self.SENSITIVE_KEYS:
result[key] = "***" # key 命中直接替换值
continue
if "path" in key.lower() and isinstance(value, str):
result[key] = self._sanitize_string(value) # 路径里的用户名脱敏
continue
result[key] = self.sanitize(value) # 递归处理嵌套结构
两类覆盖:
- key 黑名单 :命中
api_key、token等 key 名,无论值是什么都替换为*** - 值正则 :命中
sk-...、Bearer ...等模式,覆盖那些 key 名不敏感但值敏感的情况
路径里的用户名(/home/chendongqi/、/Users/alice/)也被替换为 ***,防止开发者无意中泄露本机用户名。
TRACE_SANITIZE=false 可关闭,默认开启。
设计亮点
1. 事件驱动,不是日志插桩
loop 里的可观测点发射的是语义事件(state_transition、tool_lifecycle),而不是 print 或 logger.info。事件有结构化的 payload,下游可以用代码处理;日志只能用眼睛看。
2. sink 失败不影响 loop
CompositeRuntimeEventSink 在 try/except 里调用每个 sink,失败只打 warning。可观测性基础设施是 agent 的"旁路",不是主路。主路出了问题才是大事,旁路故障不应该拖垮主路。
3. 两个 JSONL 文件,两种视角
Trace JSONL 是诊断视角:什么时候、哪一步、发生了什么,面向开发者调试。Transcript JSONL 是恢复视角:哪些事实需要保留才能重建状态,面向崩溃恢复。同一套事件流,两种用途,分别落盘。
4. fingerprint 而非 diff
prompt 变化检测用 fingerprint(sha256)而不是存全文做 diff:节省存储,比较 O(1),在 trace 里只需要看 changed_layers 就能知道哪层变了,不需要打开两个文件手动对比。
小结
| 设计选择 | 方案 | 工程价值 |
|---|---|---|
| 发射点 | 统一 _emit(),不分散 |
添加 sink 不需要修改 loop |
| 路由 | CompositeRuntimeEventSink | 多 sink 互相隔离,单个失败不影响其他 |
| 诊断日志 | TraceLogger JSONL + flush | 流式写入,崩溃窗口小,可生成 HTML 报告 |
| 恢复日志 | TranscriptRuntimeEventSink | 只写最小事实集,不混入调试细节 |
| 漂移检测 | prompt fingerprint | O(1) 对比,精确定位哪层 prompt 发生变化 |
| 脱敏 | TraceSanitizer key 黑名单 + 值正则 | 日志可分享,不担心泄露 API key |
至此,Part 4 Harness Engineering 全部完成。回顾这五篇:
- 11:控制流------单一循环,不可变状态机,完成门,终止路径穷举
- 12:上下文工程------History 与 ModelView 分离,读时投影,双路压缩触发
- 13:工具管道------并发分组,四关卡执行,两层字节预算
- 14:容错恢复------分类重试,Transcript 事实日志,UncertainAction 显式建模
- 15:可观测性------事件驱动,双 sink 路由,fingerprint 漂移检测
这五个机制共同构成了一个 agent 框架的"骨架"------决定它能跑多久、出错后能自愈多少、崩了能恢复多少、出了问题能查到多深。
关于本系列的源码
本系列所有分析均基于开源项目 MyCodeAgent。
源码里已经按照本系列文章的讲解顺序,在关键位置加入了配套注释------读文章时可以对照代码,也可以直接克隆下来自己跑、改、扩展,基于它开发你自己的 agent。
bash
git clone https://github.com/chendongqi/MyCodeAgent
cd MyCodeAgent
cp .env.example .env # 填入你的 LLM API key
uv sync
uv run python main.py
欢迎访问 PrimeSkills ------ 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页