拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入解析 Harness 可观测性:事件模型与链路追踪实践

开头说实话Harness 这个概念在 Code Agent 的圈子里已经被聊得很多了但大部分人讨论的都集中在“怎么搭”“用哪个框架”“要不要自己写”这些工程问题上。真正愿意沉下心去解剖 Harness 内部机制的文章少之又少。尤其是可观测性这一块几乎可以用“冷门中的冷门”来形容。但我个人的体会是如果你只把 Harness 当成一个“提示词包装器”或者“工具调用调度器”来用那确实不需要关心可观测性。可一旦你的 Agent 开始承载真实业务、跑长链路任务、接入多个外部工具甚至需要团队协作迭代的时候可观测性就是那个决定你是在“调 Bug”还是在“盲人摸象”的分水岭。这篇文章是 Code Agent 解剖系列的第十五篇继续围绕 Harness 设计展开重点聊可观测性。我会从设计思路、核心细节、实操落地、问题排查四个维度来拆尽量把我在实际项目中踩过的坑和沉淀下来的经验一次性讲透。适合正在自己撸 Harness、或者想把现有 Agent 工程做扎实的开发者阅读。1. 内容整体设计与思路拆解1.1 可观测性到底在观测什么先说一个最容易被误解的点很多人一听到“可观测性”第一反应就是“加日志”。这不算错但远远不够。在 Harness 这个场景下可观测性的核心对象不是你的代码跑没跑而是Agent 的决策链路。我习惯把 Harness 的可观测性拆成三个层面过程可观测Agent 在每一轮循环里看到了什么、想了什么、调用了什么工具、工具返回了什么、它如何基于这些信息修正下一步动作。状态可观测当前会话的上下文窗口占用情况、Token 消耗、缓存命中率、工具执行超时状态、Agent 内部维护的临时变量或记忆。结果可观测最终输出是否完成任务目标、质量如何、耗时多少、成本多少、有没有偏离用户原始意图。这三层如果都能被完整记录、结构化存储、并且支持事后回放那你的 Harness 就不再是一个黑盒。你可以像看录像一样重放 Agent 的整个思考过程定位它在哪一步开始跑偏或者在哪一步浪费了大量 Token。有人可能会问那这和传统的应用可观测性有什么区别区别很大。传统后端监控关心的是 QPS、延迟、错误率、CPU、内存这些“系统指标”但 Harness 的可观测性核心是“推理轨迹”。你不仅要知道 Agent 失败了还要知道它为什么失败——是工具调用参数错了还是模型上下文被挤爆了还是它对工具返回结果产生了误解。这些都是传统监控体系覆盖不到的地方。1.2 为什么 Harness 比裸调模型更需要在可观测性上下重注如果你只是用 API 裸调一个大模型那你拿到的东西无非就是“输入一段文本输出一段文本”。这种模式下可观测性确实没什么好聊的记录一下输入输出就够了。但 Harness 一旦介入情况就完全不同了。Harness 的职责是“编排”。它要决定什么时候调用模型、什么时候调用工具、怎么处理多步循环、怎么在失败时重试、怎么把中间结果反馈给模型。这意味着 Harness 本身就是一个复杂的状态机而且这个状态机的每一步都可能产生分支和副作用。我举个例子。你在 Harness 里给 Agent 配了一个“搜索工具”Agent 在推理过程中决定调用它。这个时候 Harness 要做的事情包括构造工具调用参数、执行 HTTP 请求、处理响应、把结果截断或格式化后塞回上下文、记录 Token 消耗。任何一个环节出问题都可能导致 Agent 整体行为异常。而如果你没有可观测性铺设出问题之后你连排查的切入点都找不到。再往深一层说Harness 的可观测性还有一个非常特殊的价值它是评估 Harness 本身设计好坏的重要依据。比如你的工具描述写得好不好、系统提示词有没有误导模型、上下文窗口管理策略是否合理这些问题都可以从观测数据中找到答案。换句话说可观测性不只是为了“看”更是为了“改”。它直接决定了你迭代 Harness 的速度。1.3 常见方案的取舍自研 vs 现成框架关于可观测性的实现方式业界大致有三条路线我分别说一下它们的适用场景和坑点。第一条路线完全自研。适合那种对 Harness 内部逻辑做了深度定制的团队。这时候开源框架里的观测能力往往覆盖不到你的自定义逻辑你只能自己埋点。自研的好处是灵活想记录什么就记录什么数据格式也完全由自己定义。缺点是工作量大而且容易在初期设计时漏掉重要的观测维度等后面想补就特别痛苦。第二条路线基于 OpenTelemetry 等标准协议做埋点。这是目前我比较推荐的主流方案。你不用自己定义数据模型和传输管道只需要按照标准把 trace、span、log 发出去后面的存储和可视化可以直接交给云厂商或者自建的监控平台。对 Harness 来说你可以把“一次完整的 Agent 执行”定义成一个 trace把“每次模型调用”“每次工具调用”“每次状态更新”定义成 span这样整个链路就跟传统分布式追踪一样清晰。第三条路线直接用现成的 Agent 可观测性平台。现在市面上已经有一些专门做 LLM 应用可观测性的商业产品和开源项目比如 LangSmith、Langfuse 这类。它们的优势是开箱即用对 Agent 的思维链、工具调用、Token 统计等做了专门支持。但缺点也很明显一是你的数据要往第三方平台送隐私和安全上需要考虑二是它们往往和特定的 Agent 框架绑定得比较紧如果你是自己撸的 Harness适配起来会有点别扭。我在实际项目中走的是“自研埋点数据结构 兼容 OTel 协议”的混合路线。核心的执行轨迹数据由 Harness 自己记录但导出格式兼容 OTel这样既能保证深度定制又不至于把自己锁死在某个平台上。2. 核心细节解析与实操要点2.1 事件模型把 Agent 的执行过程拆成可追踪的事件流可观测性的地基是你怎么定义“一次执行”的数据结构。我见过很多人在这一步就翻车了——他们直接把日志字符串往 stdout 一打后面想分析的时候才发现信息根本对不上号。我自己用下来比较顺手的方案是把 Harness 的所有关键动作都建模成结构化事件Event每个事件包含以下核心字段event_id全局唯一的事件 ID用于关联和索引。trace_id一次完整任务执行的唯一 ID。同一轮任务里的所有事件共享这个 ID。parent_span_id父级事件 ID。Agent 的执行是树状的一个主任务会派生出多个子动作这个字段能还原嵌套关系。event_type事件类型比如model_call_start、model_call_end、tool_call_start、tool_call_end、context_update、error、retry。timestamp事件发生的时间务必用毫秒级时间戳。payload事件的具体数据不同类型的事件有不同的 payload 结构。拿一个典型的“Agent 调用工具”动作来说它会产生两个事件。第一个是tool_call_startpayload 里包含工具名、参数、调用上下文摘要第二个是tool_call_endpayload 里包含执行结果、耗时、错误信息如果有。这两个事件通过parent_span_id关联起来就能完整还原一次工具调用的全貌。这套事件模型的好处是你既可以用trace_id拉出一整条时间线也可以用parent_span_id构建树状调用链还能按event_type做聚合统计。后面不管是要做实时监控还是离线分析都游刃有余。2.2 上下文跟踪记录每一轮会话的“记忆水位”Harness 和普通程序最大的不同在于它的“状态”很大程度上等于当前上下文窗口里塞了什么。模型能记住什么、不能记住什么、会不会突然遗忘早期指令全取决于 Harness 怎么管理上下文。所以要观测 Harness就不能不看上下文的使用情况。我在关键的时间节点都会记录以下数据当前上下文的总 Token 数和已用 Token 数。系统提示词、用户输入、工具返回结果、历史对话分别占了多少 Token。上下文截断策略触发了几次每次截断了哪些内容。模型请求的输入 Token 数和输出 Token 数以及计费相关的指标。有了这些数据你就能回答一个特别关键的问题Agent 跑到一半行为异常是因为模型本身不行还是因为上下文管理策略出了问题我遇到过很多次Agent 到后期表现突然变差表面上看是“模型能力不够”实际上查下来都是上下文里的关键信息被截断策略误杀了。没有观测数据这种问题基本只能靠猜。另外有一点要注意不同模型的上下文计算方式不一样有的按 token 数严格计数有的还包含视觉输入的特殊处理。你在设计上下文跟踪模块的时候最好抽象出一层“Token 计算接口”让不同模型各自实现避免后续接入新模型时观测数据直接对不上。2.3 工具调用的全生命周期埋点工具调用是 Harness 里最容易出错、也最值得被观测的环节。我建议给它单独做一套生命周期埋点而不是和其他事件混在一起。我把一次工具调用的生命周期拆成六个阶段决策Agent 决定调用某个工具此时记录工具名、参数、触发原因。构造Harness 把模型输出的结构化参数转换成实际可执行的请求。这一步专门用来抓参数解析错误。执行工具真正跑起来记录下来回耗时、请求头信息、目标端点。响应拿到工具返回值记录状态码、响应体大小、是否截断。反馈Harness 把结果格式化成模型可读的形式记录格式化前后的文本长度。善后处理工具调用的副作用比如清理临时文件、释放连接池资源。这六个阶段只要都埋点到位工具链路上任何一环出了问题你都能快速定位到具体阶段。尤其是“构造”和“反馈”这两个阶段它们很容易被忽略但恰恰是最容易出问题的——模型输出了非法的 JSON、工具返回了超大体积的结果导致上下文爆掉这些问题如果不单独埋点排查起来极其痛苦。2.4 成本与延迟观测别等到月底账单出来才拍大腿除了正确性成本和延迟也是 Harness 可观测性必须覆盖的维度。很多团队把 Agent 跑通了就觉得万事大吉等到月底收到 API 账单才开始慌。但成本问题如果等到账单出来才发现那就什么都晚了。我这里说的是实时成本估算。在 Harness 里每次模型调用结束后你都已经拿到了输入 Token 数和输出 Token 数再结合当前模型的单价成本是可以即时算出来的。把这些数据按任务维度聚合成“一次任务总成本”“单轮平均成本”“工具调用额外消耗”等指标你就能在 Agent 跑得异常慢或者异常贵的时候及时止损。延迟也一样。每个 span 的耗时都是现成的你只需要做一层聚合就能看到“模型推理占用多少时间”“工具调用占用多少时间”“上下文整理占用多少时间”。很多时候 Agent 整体变慢不是因为模型变慢了而是因为某个工具接口的响应时间飘了。这些信息不观测你连优化方向都找不到。2.5 调试模式在不改代码的情况下查看完整执行轨迹最后说一个我自己特别依赖的功能调试模式。日常开发的时候我们当然可以去读原始日志但那效率太低了。我把 Harness 做成支持一个debug开关开启后会把上述所有事件组装成一条人类可读的执行轨迹以类似下面这种缩进树的形式输出[trace: 8f3a1c] 开始执行用户任务 [model_call: 01] 调用模型 claude-3-5-sonnet [tool_call: 02] 调用工具 web_search 参数: {query: Harness 可观测性} 结果: 返回 8 条记录共 2048 token [tool_call: 03] 调用工具 code_runner 参数: {language: python, code: ...} 结果: 执行成功输出 142 token [model_call: 04] 调用模型 claude-3-5-sonnet [context_update] 注入工具结果上下文占用 68% [final_answer] 生成最终回复这种输出格式看着简单但实际价值极大。你在本地调试的时候一行一行看下来Agent 在哪里思考、在哪里调用工具、在哪里可能跑偏一目了然。而且因为它是从结构化事件生成的你完全可以把它保存在日志文件里事后回放。3. 实操过程与核心环节实现3.1 从零搭建一个最小可用的可观测模块我只讲思路和关键代码片段不贴完整项目因为完整项目太大了。但照着这个骨架你完全可以自己搭出一个能用的版本。首先定义一个基础的事件类。我用 Python 写一个示例import json import time import uuid from enum import Enum from typing import Any, Dict, Optional class EventType(str, Enum): TASK_START task_start MODEL_CALL_START model_call_start MODEL_CALL_END model_call_end TOOL_CALL_START tool_call_start TOOL_CALL_END tool_call_end CONTEXT_UPDATE context_update ERROR error class Event: def __init__( self, event_type: EventType, trace_id: str, parent_span_id: Optional[str] None, **payload: Any, ): self.event_id uuid.uuid4().hex self.trace_id trace_id self.parent_span_id parent_span_id self.event_type event_type self.timestamp int(time.time() * 1000) self.payload payload def to_dict(self) - Dict[str, Any]: return { event_id: self.event_id, trace_id: self.trace_id, parent_span_id: self.parent_span_id, event_type: self.event_type.value, timestamp: self.timestamp, payload: self.payload, } def to_json(self) - str: return json.dumps(self.to_dict(), ensure_asciiFalse)这只是最基础的定义。实际项目中你可能还需要把 timestamp 统一改成 UTC避免跨时区记录的混乱payload 里的数据结构也需要定义得更严格一些比如工具调用的参数不能直接塞自定义对象要先序列化成 JSON 安全的类型。接下来是一个简单的 Tracer 类负责创建事件并写入事件流import logging from typing import Optional logger logging.getLogger(harness.observability) class Tracer: def __init__(self, trace_id: str): self.trace_id trace_id self.event_list [] def record( self, event_type: EventType, parent_span_id: Optional[str] None, **payload: Any, ) - Event: event Event( event_typeevent_type, trace_idself.trace_id, parent_span_idparent_span_id, **payload ) self.event_list.append(event) logger.debug(event.to_json()) return event有了 Tracer你在 Harness 的各个关键节点就可以这样埋点了def run_tool(self, tracer: Tracer, tool_ctx: dict): start_event tracer.record( EventType.TOOL_CALL_START, tool_nametool_ctx[name], argumentstool_ctx[arguments] ) try: result self.execute_tool(tool_ctx) except Exception as e: tracer.record( EventType.ERROR, parent_span_idstart_event.event_id, errorstr(e) ) raise tracer.record( EventType.TOOL_CALL_END, parent_span_idstart_event.event_id, result_summaryresult.summary(), elapsed_msresult.elapsed_ms ) return result注意这里我用parent_span_id把TOOL_CALL_START和TOOL_CALL_END关联成了一个可追踪的 span。这样后续可视化的时候这两个事件就能被渲染成树上的同一个节点。3.2 事件流的持久化方案选型事件记录下来之后接下来要考虑的就是怎么存。这个选择直接决定了你后面能不能高效检索和分析。对于中小规模的个人项目我推荐直接用 SQLite 或者 PostgreSQL 加一张 JSONB 表。事件本身是半结构化的JSONB 字段既能保证灵活性又能在关键字段上建索引。比如你可以建一个这样的表CREATE TABLE harness_events ( id BIGSERIAL PRIMARY KEY, trace_id TEXT NOT NULL, parent_span_id TEXT, event_type TEXT NOT NULL, timestamp TIMESTAMPTZ NOT NULL, payload JSONB NOT NULL ); CREATE INDEX idx_harness_events_trace ON harness_events (trace_id, timestamp); CREATE INDEX idx_harness_events_type ON harness_events (event_type);查询某个任务完整轨迹的时候直接按trace_id过滤并按timestamp排序就能拿到一串完整的事件序列。要分析某种错误类型的时候按event_type加payload里的关键字过滤也非常方便。如果你的项目已经用上了 OpenTelemetry 体系那更推荐直接把事件转换成 OTel 的 Span 结构发到 Collector。好处是你可以直接复用 Grafana、Jaeger 这些现成的可视化工具。坏处是 OTel 的标准模型并不完全贴合 Agent 的执行特点尤其是上下文状态和 Token 消耗这些非 Span 性质的数据需要你用 Attribute 去承载用起来有点别扭。我的建议是小项目先上 SQLite/PostgreSQL数据量大了或者需要团队协作时再迁移到 OTel 体系。事件模型本身是兼容的迁移成本不会太高。3.3 可视化从原始日志到可读的时间线光有结构化事件还不够人眼是没法直接扫描几千行 JSON 的。一个简单但有效的可视化方案是做一个静态 HTML 上报器。每次任务执行完Harness 把事件列表转换成一个自包含的 HTML 文件里面用时间线和树状结构展示整个执行过程。这个 HTML 报告可以包含以下模块概览卡片任务结果、总耗时、总 Token 消耗、预估成本。时间线视图所有事件按时间顺序排布每个 span 显示耗时和状态。调用树视图按父子关系展示模型调用和工具调用的嵌套结构。上下文水位图直观展示每一轮上下文占用量的变化曲线。原始事件 JSON供深度排查使用。我强烈建议做这个功能的因为它的投入产出比特别高。你只需要写一次模板后面每一次 Agent 跑完都能自动生成一份“过程报告”。排查问题的时候打开报告比翻日志高效十倍还不止。3.4 一个完整的埋点流程示例为了让你更直观地理解整个埋点体系怎么协同工作我模拟一个场景用户让 Agent 写一个 Python 脚本然后执行它再根据执行结果修改脚本。整个流程在 Harness 里会产生如下事件序列1. task_start tracetask_001, payload{task_desc: 写脚本并执行} 2. model_call_start tracetask_001, payload{model: gpt-4o, prompt_tokens: 1500} 3. model_call_end tracetask_001, payload{output_tokens: 800, finish_reason: tool_calls} 4. tool_call_start tracetask_001, payload{tool_name: code_writer, arguments: {language: python, code: ...}} 5. tool_call_end tracetask_001, payload{elapsed_ms: 120, file_path: /tmp/script.py} 6. tool_call_start tracetask_001, payload{tool_name: code_runner, arguments: {command: python /tmp/script.py}} 7. tool_call_end tracetask_001, payload{elapsed_ms: 800, exit_code: 1, stderr: SyntaxError} 8. model_call_start tracetask_001, payload{input_tokens: 2600} 9. context_update tracetask_001, payload{total_tokens: 3200, max_tokens: 8000} 10. model_call_end tracetask_001, payload{output_tokens: 600, finish_reason: stop} 11. tool_call_start tracetask_001, payload{tool_name: code_writer, arguments: {language: python, code: 修正版}} 12. tool_call_end tracetask_001, payload{elapsed_ms: 100, file_path: /tmp/script.py} 13. task_end tracetask_001, payload{success: true, total_elapsed_ms: 5200, total_cost: 0.012}这 13 个事件完整记录了一次 Agent 从接收到任务到最终完成的全部关键过程。你看事件 7 和事件 8 的衔接就能知道Agent 在脚本第一次执行失败之后把错误信息塞回了上下文然后开始了新一轮推理。这就是 Agent 的“自我修正”行为。没有观测数据的话你只能看到最终结果永远不知道中间经历了什么。4. 常见问题与排查技巧实录4.1 工具返回内容太大把上下文撑爆了这是我在实际使用中遇到最多的问题。Agent 调用了一个查询工具结果返回了十几万字的文本Harness 不加处理直接把全文塞回上下文下一轮模型调用直接超出上下文窗口。排查思路很简单看事件流里的tool_call_end事件对比它的响应体大小和下一轮model_call_start的输入 token 数。如果发现工具返回值异常大那就是上下文管理策略没有做好截断。解决方案是在 Harness 里给每个工具加上返回结果长度上限超出的部分用“前 N 个字符 省略号 后 M 个字符”的方式保留关键信息。另外还可以在tool_call_end事件里记录截断前后的长度方便后续分析截断是否影响了模型判断。4.2 模型输出了非法 JSON工具调用参数解析失败另一个高频问题。模型在生成工具调用参数时偶尔会输出残缺的 JSON比如少了右大括号、字符串没闭合、或者多了尾逗号。一旦解析失败Harness 通常只能整轮重试白白浪费一次模型调用的开销。我的排查经验是先看事件流里的tool_call_start事件把原始参数打印出来人工确认非法 JSON 的具体形态。然后针对性地在解析层做容错。比如可以先尝试严格的json.loads失败后再进入“修复模式”对常见的 JSON 错误做自动修复。这个过程也应该被埋点记录下来方便统计修复成功率。4.3 长任务跑到后面Agent 表现越来越差这个问题的本质通常是上下文被污染或者关键信息被截断。事件流里能很清晰地看到context_update事件的 token 占用变化——如果上下文占用率已经超过了 80%那模型的表现下滑是必然的。我的建议是在这种长任务场景下Harness 的上下管管理策略需要更激进一些。比如对早期系统提示词做压缩缓存对工具返回结果做摘要而不是完整保留对历史对话做窗口滑动。每一次策略触发都应该记录在context_update事件里这样你才能验证策略是否真的有效。4.4 可观测性本身拖慢了执行速度怎么取舍埋点太多Event 序列化、日志写入都消耗时间Agent 的整体延迟明显上升。这就涉及到一个工程取舍问题。我的做法是把事件流写入设计成异步批量提交。事件先进入内存队列后台线程定期批量写入避免阻塞主执行链路。同时在调试模式下可以开启全量埋点在生产模式下只保留关键事件的低采样率埋点。这样既不影响日常开发时的排查体验又能控制生产环境的额外开销。最后分享一个我在实际项目中反复验证过的小技巧永远给每个事件加上trace_id和parent_span_id这两个字段形成树的关联。很多人在一开始做可观测性的时候只记录日志文本觉得“反正我能搜索关键词”。但等 Agent 的逻辑复杂起来一次任务会派生几十上百个步骤没有树状关联的话你根本没法高效地还原一条完整链路。这个字段设计看似多花了一点心思但它在排查问题、统计调用链路径、分析 Agent 行为模式时价值会越来越大。至少在 Code Agent 这个方向上我认为可观测性就应该跟执行的逻辑“长”在一起而不是事后补救。你越早把它作为 Harness 设计的一等公民后面迭代 Agent 的效率就越高。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门