Code Agent 解剖(15):Harness 设计之五——可观测性

最后一块拼图

前四篇解剖了 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_keytoken 等 key 名,无论值是什么都替换为 ***
  • 值正则 :命中 sk-...Bearer ... 等模式,覆盖那些 key 名不敏感但值敏感的情况

路径里的用户名(/home/chendongqi//Users/alice/)也被替换为 ***,防止开发者无意中泄露本机用户名。

TRACE_SANITIZE=false 可关闭,默认开启。


设计亮点

1. 事件驱动,不是日志插桩

loop 里的可观测点发射的是语义事件(state_transitiontool_lifecycle),而不是 printlogger.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 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。

更多实用知识和有趣产品,欢迎访问我的个人主页

相关推荐
冬奇Lab17 分钟前
开源项目第203期:Cumora — AI Agent 与人类同队的跨平台团队协作工具
人工智能·开源·资讯
天远Date Lab21 分钟前
零信任架构实战:基于天远双人婚姻评估查询构建自动化房产按揭联合审查网关
运维·人工智能·架构·自动化
甲维斯27 分钟前
Claude Opus5 开发中转应用平台的5万字项目文档!
人工智能
hahaha601642 分钟前
HLS高层次综合设计技巧--C++类和模板
图像处理·人工智能·算法·计算机视觉
荷蒲1 小时前
【小白量化Qbuddy】用AI设计miniQMT指标公式计算量化平台
人工智能·python·机器人
猎头南楼1 小时前
VLA 模型在双臂机器人操作中的工程落地:从 pi0 到 diffusion policy 的实践思考
人工智能·机器人
阿童木写作1 小时前
跨境电商图片翻译工具,批量翻译视频字幕一键抠图
人工智能·python·音视频
ITmaster07311 小时前
从零开始实现一个 AI Agent CLI
人工智能
user-猴子2 小时前
钛媒体测五款、光锥智能测WorkBuddy、用户测AiPy——三组实测交叉对比,哪款AI办公工具最值得下载?
人工智能