一个 Agent 跑了快一个小时,在最后一步失败了。界面只留给你一行 “Tool execution failed” ,点开日志,是几千行没有结构的信息。你回头问 Agent 为什么失败,它只是推测“可能是网络波动”。但问题是:它调用了哪些工具?每次调用花了多少 token?第二十步改了什么状态导致最终失败?如果没有结构化追踪,这些问题你根本回答不上来。
这类问题在短任务里还不明显,反正几秒钟就能重跑一次。可一旦进入长周期 Agent(Long-Horizon Agent)场景,比如自动化数据分析、多步骤业务编排、长时间知识库整理,一次运行会涉及大量工具调用和状态变更,失败和成本失控的风险会快速放大。更麻烦的是,调试者只能“跑完看结果”,无法在运行过程中理解 Agent 到底在做什么。
这正是 Live Trace Model(实时追踪模型)要解决的问题:把 Agent 内部的执行过程,变成一条可以被解析和订阅的结构化事件流,让观察者(Observer)在运行期间就能实时看到“它现在在干什么、接下来打算干什么、这一步花了多少钱”。这篇文章会把这个模型拆开,从事件结构、实时流、解析器到观察者,用一套可运行的最小实现带你跑通完整链路。
读完你应该能回答三个问题:长周期 Agent 的可观测性为什么不能用普通日志解决;一个与框架无关的实时追踪流应该采集什么、怎么落地;Observer 要如何消费流,才能做到实时告警和事后回放。
1. 这篇文章真正要解决的问题
1.1 长周期 Agent 的“黑盒”问题
传统的 Agent 调试方式,本质上是“事后日志”。常见做法是在 Agent 执行循环里打印几句话:调用了哪个工具、返回了什么结果。短任务这样做没问题,因为运行时间短、步骤少,人类可以盯住屏幕。但长周期 Agent 的复杂度完全不在一个量级。
长周期 Agent 有几个非常突出的特性:
- 步骤多。一次任务可能包含几十次甚至上百次工具调用,规划、执行、校验交替出现。
- 运行时间长。从几分钟到几小时,中途不能靠人工盯屏。
- 有外部副作用。工具调用会真实影响外部系统,比如发消息、改数据、调接口,出错了不能只靠重试解决。
- 成本敏感。一次长时间运行会消耗大量 token,如果到后半段才发现方向错了,浪费已经被放大。
在这种场景下,普通日志的缺陷暴露无遗。日志是“打印给人看”的,不是“流式结构化数据”,存在三方面硬伤:
第一,没有统一的事件模型。不同 Agent 步骤打印的格式完全靠自己约定,想用程序去解析非常吃力。
第二,没有父子关系和时序关系。一次工具调用是哪个规划步骤发起的?状态变更发生在哪一层?普通日志无法表达 span 之间的嵌套关系。
第三,没有实时性接口。日志写进文件后,要看只能 tail,要告警只能定期扫,谈不上事件流订阅。
这篇文章真正要解决的问题,就是把 Agent 的“内部过程”变成一条标准的实时事件流,让调试者、监控系统、评测系统都成为这条流的 Observer。
1.2 谁需要实时追踪模型
先说结论:只要 Agent 的应用场景开始涉及长时间运行、成本控制或多人协作,可观测性就不再是可选组件,而是一种基础设施。
以下几类读者最需要关注这个方向:
- Agent 应用开发者。你正在用 LangChain、AutoGen、自研框架做多步骤 Agent,经常遇到“能跑但不知道跑得对不对”的问题。
- AI Infra / 平台工程师。公司里多个业务方都在接 Agent,你需要统一的可观测方案,不能每个团队各写一套日志。
- 评测与算法工程师。你想知道模型在某条复杂任务里哪一步开始偏离,需要完整 trace 来定位“拐点”。
如果你只是写一个单轮问答的示例,那 Live Trace Model 确实有点重。但凡你的 Agent 会循环调用工具、会变更多轮状态、会产生可感知的成本,这层基础设施就值得提前设计。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Live Trace Model 的核心概念与适用场景
2.1 几个关键术语
在展开实现之前,必须先把概念边界讲清楚,否则很容易和传统链路追踪混在一起。
Long-Horizon Agent(长周期智能体)
指需要执行多轮规划、多步工具调用、长时间运行才能完成任务的 Agent。它区别于单轮问答型 Agent 的关键点在于:中途存在大量中间状态,且不能接受“失败后无痕重开”。
Trace(追踪)
一次 Agent 任务从开始到结束的完整执行记录。一个 Trace 由若干事件或 Span 组成,包含规划、状态变更、模型调用、工具调用、错误、最终结果等。
Live Trace Model(实时追踪模型)
一种面向流式处理设计的追踪模型。它不只是“事后存储执行记录”,而是强调事件在产生的同时就被写入一个可订阅的流,观察者可以实时消费、过滤、聚合和告警。
Observer(观察者)
消费 Trace 流的一方。可以是人工调试界面、自动化监控系统、评测框架、成本控制组件。Observer 和 Agent 之间通过事件流解耦,Agent 不需要知道谁在看。
2.2 与传统日志和框架回调的差异
很多人会觉得:这不就是“打日志 + 日志采集”吗?关键区别在于模型设计。
| 维度 | 传统日志 | Agent 框架自带回调 | Live Trace Model |
|---|---|---|---|
| 数据结构 | 非结构化文本 | 框架私有对象 | 统一结构化事件模型 |
| 调用关系 | 无 | 部分有 | 显式 parent_span_id |
| 实时消费 | 文件 tail / 采集器 | 同步回调,无法回放 | 流式订阅 + 缓存回放 |
| 对外依赖 | 低 | 绑定框架 | 与框架解耦 |
| 适合场景 | 单步排错 | 多步可观测 | 长周期、多 Observer、实时决策 |
这里要强调一个容易误解的点:Agent 框架自带的回调其实已经做了不少事情,比如 LangChain 有 on_llm_start、on_tool_end 这类回调。但它们的问题有两个:一是事件模型绑定框架,换框架就要重写;二是回调往往在 Agent 进程内同步执行,观察者逻辑如果动作太重,会拖慢 Agent 本身。
Live Trace Model 的方法是:Agent 内部只负责“发射事件”,事件统一进入一个流,Observer 异步消费。这样 Agent 不关心观察者是谁,观察者也不依赖 Agent 的具体实现。
2.3 实时追踪流的基本形态
这套模型的数据流可以概括为一条单向链路:
code复制Agent 执行循环 -> TraceEmitter -> LiveTraceStream -> TraceParser -> Observer
- TraceEmitter 埋在 Agent 内部,负责把规划、工具调用、模型调用等行为变成标准事件。
- LiveTraceStream 是事件通道,承担缓冲、削峰和背压。
- TraceParser 负责排序、聚合、重建调用关系。
- Observer 订阅流,消费事件,做出展示、告警或干预决策。
这个模型本身不绑定具体技术栈。事件可以用 JSON Lines 编码,通道可以用内存队列、Redis Streams、Kafka 或者文件尾随。本文先实现一个内存版最小闭环,让你把链路完整跑通。
3. 追踪流的数据结构与事件设计
3.1 事件类型
设计数据结构时,第一步是确定事件类型。如果事件类型太粗,Observer 无法区分“规划”和“工具调用”;太细则每个类型都缺乏通用性。建议至少覆盖以下六类:
| 事件类型 | 含义 | 典型 payload 字段 |
|---|---|---|
| plan | Agent 生成了计划或子目标 | goal、plan_step、parent_plan |
| state_change | Agent 内部状态发生变更 | step、key_fields_before、key_fields_after |
| llm_generate | 调用一次大模型生成 | model、prompt_tokens、completion_tokens、cost |
| tool_call | 调用一个外部工具 | tool、arguments、retry_count |
| tool_result | 工具返回结果 | tool、status、summary、cost |
| error | 出现不可恢复错误 | message、error_code、context |
| finish | 任务正常结束 | result_summary、total_runtime_ms |
示例中我还会加入一个轻量的 finish 事件,用来标识任务正常结束。这不是必须的,但 Observer 判断“正常结束还是中途失败”会非常方便。
3.2 字段设计
每个事件建议包含公共字段和业务 payload 两部分。公共字段是所有事件类型共享的,需要满足三个能力:事件全局唯一、能表达调用关系、能处理时序。
| 字段 | 类型 | 作用 |
|---|---|---|
| trace_id | string | 一次 Agent 任务的唯一标识 |
| span_id | string | 当前步骤的唯一标识 |
| parent_span_id | string? | 父步骤标识,用于重建调用树 |
| agent_id | string | 哪个 Agent 产生的事件 |
| session_id | string | 属于哪次用户会话 |
| event_type | enum | 事件类型 |
| payload | object | 业务细节,灵活扩展 |
| ts | float | 事件时间戳 |
| seq | int | 事件序号,用于修复乱序 |
这里 seq 非常关键。因为事件进入流之后,消费端看到的顺序未必是产生顺序。比如 Agent 是多线程执行、或者通道是分布式消息队列时,顺序会乱。seq 由发射端单调递增分配,Parser 侧用 seq 排序。
3.3 三种常见承载通道
实际工程里,LiveTraceStream 的承载通道有三种常见选择。理解它们有助于你根据规模选型:
- 内存队列:最简单,适合单机、低并发、教学演示。缺点是进程重启就丢。
- Redis Streams:适合多实例 Agent 汇聚到一个观察平台,支持消费组和 ack 机制,部署成本低。
- Kafka:适合大规模生产环境,消息可持久、可回放、生态成熟,但运维成本高。
注意:通道的取舍不会改变事件模型本身。设计时优先把事件结构定义好,通道可以随时替换。
4. 环境准备与最小演示项目结构
4.1 环境要求
为了把风险降到最低,本文的演示实现只用 Python 标准库,不依赖任何第三方包。
- Python 3.10 及以上。
- 命令行终端。
- 不需要 GPU,不需要真实大模型 API。
如果你想把它接到真实 Agent 框架,后续只需要把模拟 Agent 换成你自己的执行循环。事件模型和流式处理逻辑完全不用改。
4.2 项目目录结构
建议按如下目录组织代码:
code复制trace_demo/
├── trace_model/
│ ├── __init__.py
│ ├── event.py
│ ├── stream.py
│ ├── emitter.py
│ ├── parser.py
│ └── observer.py
├── demo_simulate.py
└── main_demo.py
先创建空目录和空初始化文件:
bash复制mkdir -p trace_demo/trace_model
touch trace_demo/trace_model/__init__.py
然后逐个文件填写代码。
5. 核心实现:事件、流与发射器
5.1 定义事件模型
文件路径:trace_model/event.py
python复制from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field, asdict
from enum import Enum
from typing import Any, Optional
class TraceEventType(str, Enum):
PLAN = "plan"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
LLM_GENERATE = "llm_generate"
STATE_CHANGE = "state_change"
ERROR = "error"
FINISH = "finish"
@dataclass
class TraceEvent:
trace_id: str
span_id: str
parent_span_id: Optional[str]
agent_id: str
session_id: str
event_type: TraceEventType
payload: dict[str, Any]
ts: float = field(default_factory=time.time)
seq: int = 0
def to_dict(self) -> dict[str, Any]:
d = asdict(self)
d["event_type"] = self.event_type.value
return d
这段代码定义了全链路的事件基座。trace_id 和 session_id 用来区分任务和会话,span_id 和 parent_span_id 用来表达调用树关系。seq 默认为 0,实际运行时由发射端填充。
要注意 to_dict 里的处理:event_type 是 str 枚举,直接 asdict 会保留枚举对象,JSON 序列化会出问题,所以手动替换成 .value。
5.2 实现实时追踪流
文件路径:trace_model/stream.py
python复制from __future__ import annotations
import queue
import threading
from typing import Optional
from trace_model.event import TraceEvent
class LiveTraceStream:
"""内存版实时追踪流。生产环境可替换为 Redis Streams / Kafka。"""
def __init__(self, maxsize: int = 10_000):
self._queue: queue.Queue[TraceEvent] = queue.Queue(maxsize=maxsize)
self._closed = False
def publish(self, event: TraceEvent) -> None:
if self._closed:
raise RuntimeError("trace stream is closed")
self._queue.put(event, block=True)
def subscribe(self, timeout: float = 0.5) -> Optional[TraceEvent]:
try:
return self._queue.get(timeout=timeout)
except queue.Empty:
return None
def close(self) -> None:
self._closed = True
这个类包装了 queue.Queue,对外暴露 publish 和 subscribe 两个语义清晰的方法。maxsize 是对背压的第一个控制手段:如果观察者消费不及时,publish 会在 put 处阻塞,从而反向限制 Agent 的发射速度,避免内存被事件撑爆。
5.3 实现 TraceEmitter
文件路径:trace_model/emitter.py
python复制from __future__ import annotations
import itertools
import threading
import uuid
from typing import Optional
from trace_model.event import TraceEvent, TraceEventType
from trace_model.stream import LiveTraceStream
class TraceEmitter:
"""埋在 Agent 内部的追踪发射器。"""
def __init__(self, stream: LiveTraceStream, agent_id: str, session_id: str):
self._stream = stream
self._agent_id = agent_id
self._session_id = session_id
self._trace_id = uuid.uuid4().hex
self._seq = itertools.count(start=1)
self._lock = threading.Lock()
def emit(
self,
event_type: TraceEventType,
payload: dict,
parent_span_id: Optional[str] = None,
) -> TraceEvent:
with self._lock:
event = TraceEvent(
trace_id=self._trace_id,
span_id=uuid.uuid4().hex,
parent_span_id=parent_span_id,
agent_id=self._agent_id,
session_id=self._session_id,
event_type=event_type,
payload=payload,
seq=next(self._seq),
)
self._stream.publish(event)
return event
TraceEmitter 是整个埋点模型的关键。它有四个设计点需要注意:
- 每个 Agent 实例持有一个 emitter,
trace_id在整个任务期间保持不变。 seq使用itertools.count单调递增,配合threading.Lock,保证多线程并发调用 emit 时不会出现重复或回退的序号。span_id每次生成一个新的,parent_span_id由调用方显式传入,从而表达调用树。- 事件生成在锁内完成,发布在锁外执行。这意味着锁只保护事件生成,不长期持有,降低对 Agent 主流程的性能影响。
6. 解析流与观察者:完整代码与运行
6.1 TraceParser 解析器
文件路径:trace_model/parser.py
python复制from __future__ import annotations
import json
import threading
from collections import defaultdict
from typing import Any
from trace_model.event import TraceEvent, TraceEventType
class TraceParser:
"""解析实时追踪流:排序、分组、重建调用关系。"""
def __init__(self, window: int = 200):
self._window = window
self._buffers: dict[str, list[TraceEvent]] = defaultdict(list)
self._lock = threading.Lock()
def push(self, event: TraceEvent) -> None:
with self._lock:
self._buffers[event.trace_id].append(event)
if len(self._buffers[event.trace_id]) > self._window:
self._buffers[event.trace_id].pop(0)
def get_trace(self, trace_id: str) -> list[TraceEvent]:
with self._lock:
events = sorted(self._buffers.get(trace_id, []), key=lambda e: e.seq)
return events
def list_traces(self) -> list[str]:
with self._lock:
return list(self._buffers.keys())
def to_json_lines(self, trace_id: str) -> str:
events = self.get_trace(trace_id)
return "\n".join(
json.dumps(e.to_dict(), ensure_ascii=False) for e in events
)
def summary(self, trace_id: str) -> dict[str, Any]:
events = self.get_trace(trace_id)
tool_calls = [e for e in events if e.event_type == TraceEventType.TOOL_CALL]
errors = [e for e in events if e.event_type == TraceEventType.ERROR]
total_cost = 0.0
for e in events:
if e.event_type == TraceEventType.LLM_GENERATE:
usage = e.payload.get("usage", {})
total_cost += usage.get("cost", 0.0)
return {
"trace_id": trace_id,
"event_count": len(events),
"tool_call_count": len(tool_calls),
"error_count": len(errors),
"estimated_cost": round(total_cost, 4),
}
Parser 要解决的关键问题是“解析流”。它内部用 defaultdict(list) 按 trace_id 缓存事件,push 时只保留窗口内的最新事件,避免无限增长。
get_trace 返回按 seq 排序的事件列表,这是解决事件乱序的兜底手段。summary 为 Observer 提供任务级别的统计摘要,方便做自动化的成本汇总和错误计数。
6.2 ConsoleObserver 观察者
文件路径:trace_model/observer.py
python复制from __future__ import annotations
import threading
import time
from typing import Optional
from trace_model.event import TraceEvent, TraceEventType
from trace_model.parser import TraceParser
from trace_model.stream import LiveTraceStream
class ConsoleObserver:
"""
最小观察者:后台线程消费实时流,控制台输出,并按错误频率告警。
"""
def __init__(
self,
stream: LiveTraceStream,
parser: TraceParser,
alert_interval: float = 3.0,
):
self._stream = stream
self._parser = parser
self._alert_interval = alert_interval
self._stop_event = threading.Event()
self._running = False
def start(self) -> None:
self._running = True
t = threading.Thread(target=self._run, daemon=True)
t.start()
def _run(self) -> None:
last_alert_ts = 0.0
while not self._stop_event.is_set():
event = self._stream.subscribe(timeout=0.2)
if event is None:
continue
self._parser.push(event)
print(
f"[obs] seq={event.seq:03d} "
f"type={event.event_type.value:>12} "
f"trace={event.trace_id[:8]}"
)
if event.event_type == TraceEventType.ERROR:
now = time.time()
if now - last_alert_ts > self._alert_interval:
print(
f"[alert] trace {event.trace_id[:8]} 出现错误: "
f"{event.payload.get('message')}"
)
last_alert_ts = now
def stop(self) -> None:
self._stop_event.set()
self._running = False
Observer 使用后台线程消费队列,这是解耦的关键:Agent 主线程 emit 事件不会阻塞在观察者的打印或告警逻辑上。stop_event 用来安全退出循环。
这里有个故意简化处理:通过 trace_id[:8] 缩短输出,因为真实 trace_id 是 32 位 hex,控制台太占空间。使用摘要式输出是观察者的常见设计。
6.3 模拟一个长周期 Agent
文件路径:demo_simulate.py
python复制from __future__ import annotations
import time
from trace_model.emitter import TraceEmitter
from trace_model.event import TraceEventType
class SimulatedLongHorizonAgent:
"""模拟一个执行 5 个步骤的长周期 Agent,不调用真实 LLM。"""
def __init__(self, emitter: TraceEmitter):
self._emitter = emitter
self._state = {"step": 0, "cache": {}}
def run(self) -> None:
plan_span = self._emitter.emit(
TraceEventType.PLAN,
{"goal": "整理季度数据并输出报告"},
)
for step in range(1, 6):
self._state["step"] = step
self._emitter.emit(
TraceEventType.STATE_CHANGE,
{"step": step, "state": dict(self._state)},
parent_span_id=plan_span.span_id,
)
# 每次循环模拟一次 LLM 生成,便于统计成本
self._emitter.emit(
TraceEventType.LLM_GENERATE,
{
"model": "demo-llm",
"prompt_tokens": 120 + step * 10,
"completion_tokens": 40 + step * 5,
"duration_ms": 80 + step * 20,
"usage": {"cost": 0.001 * step},
},
parent_span_id=plan_span.span_id,
)
tool_result = self._call_tool(step, plan_span.span_id)
if tool_result["status"] == "error":
self._emitter.emit(
TraceEventType.ERROR,
{
"message": "tool failed",
"tool": tool_result["tool"],
"error_code": tool_result.get("error_code"),
},
parent_span_id=plan_span.span_id,
)
return
self._emitter.emit(
TraceEventType.FINISH,
{"state": dict(self._state), "result": "report_ok"},
parent_span_id=plan_span.span_id,
)
def _call_tool(self, step: int, parent_span_id: str) -> dict:
time.sleep(0.05)
tools = [
"search_quarterly_data",
"load_sql",
"run_python",
"gen_chart",
"write_markdown",
]
tool_name = tools[step - 1]
call_span = self._emitter.emit(
TraceEventType.TOOL_CALL,
{"tool": tool_name, "arguments": {"step": step}},
parent_span_id=parent_span_id,
)
# 制造一个可观测的失败:第 2 步 SQL 超时
if step == 2:
self._emitter.emit(
TraceEventType.TOOL_RESULT,
{
"tool": tool_name,
"status": "error",
"error_code": "SQL_TIMEOUT",
},
parent_span_id=call_span.span_id,
)
return {"status": "error", "tool": tool_name, "error_code": "SQL_TIMEOUT"}
self._emitter.emit(
TraceEventType.TOOL_RESULT,
{"tool": tool_name, "status": "ok", "rows": step * 10},
parent_span_id=call_span.span_id,
)
return {"status": "ok", "tool": tool_name}
这个模拟 Agent 展示了一个很重要的埋点模式:每个工具调用先创建一个 TOOL_CALL span,然后工具返回时再发一个带 parent_span_id 的 TOOL_RESULT 事件。 这样 Observer 可以清楚地区分“调用开始”和“调用结束”,也方便计算每个工具的真实耗时。
第 2 步故意制造了一个 SQL 超时错误,目的是验证 Observer 的告警能力和 Parser 的错误统计是否正常工作。
6.4 启动演示
文件路径:main_demo.py
python复制import time
from trace_model.emitter import TraceEmitter
from trace_model.observer import ConsoleObserver
from trace_model.parser import TraceParser
from trace_model.stream import LiveTraceStream
from demo_simulate import SimulatedLongHorizonAgent
if __name__ == "__main__":
stream = LiveTraceStream(maxsize=10_000)
parser = TraceParser(window=200)
observer = ConsoleObserver(stream, parser, alert_interval=1.0)
observer.start()
# 模拟两个并行 Agent
for i in range(2):
emitter = TraceEmitter(stream, agent_id=f"agent-{i}", session_id="session-demo")
agent = SimulatedLongHorizonAgent(emitter)
agent.run()
# 等待观察者消费事件
time.sleep(2.0)
print("\n=== parser summary ===")
for trace_id in parser.list_traces():
print(parser.summary(trace_id))
print(parser.to_json_lines(trace_id))
observer.stop()
stream.close()
在 trace_demo 目录下运行:
bash复制python main_demo.py
主程序启动了三个关键组件:LiveTraceStream 作为事件通道,TraceParser 负责缓存和统计,ConsoleObserver 在后台线程实时消费。随后创建两个模拟 Agent 并行发射事件。等待两秒是为了让后台线程完成消费,实际项目中应该用队列深度或消费确认来同步,而不是固定 sleep。
7. 运行结果与效果验证
7.1 预期输出
运行后,终端输出大致如下(trace_id 和时间戳会变化):
text复制[obs] seq=001 type=plan trace=1f9a2c8b
[obs] seq=002 type=state_change trace=1f9a2c8b
[obs] seq=003 type=llm_generate trace=1f9a2c8b
[obs] seq=004 type=tool_call trace=1f9a2c8b
[obs] seq=005 type=tool_result trace=1f9a2c8b
[obs] seq=006 type=state_change trace=1f9a2c8b
[obs] seq=007 type=llm_generate trace=1f9a2c8b
[obs] seq=008 type=tool_call trace=1f9a2c8b
[obs] seq=009 type=tool_result trace=1f9a2c8b
[alert] trace 1f9a2c8b 出现错误: tool failed
=== parser summary ===
{'trace_id': '1f9a2c8b...', 'event_count': 9, 'tool_call_count': 2, 'error_count': 1, 'estimated_cost': 0.003}
{'trace_id': '2a7c4d1e...', 'event_count': 16, 'tool_call_count': 5, 'error_count': 0, 'estimated_cost': 0.015}
第一个 Agent 在第 2 步失败,事件数只有 9 条,没有 finish 事件,会触发 [alert]。第二个 Agent 走完 5 步,事件数 16 条,正常结束。
7.2 如何判断追踪模型是否生效
从观察者的角度,判断标准有三个:
- 事件完整性:Agent 的每个关键动作都产生了一条或多条事件,包括 plan、state_change、llm_generate、tool_call、tool_result。
- 调用关系正确:
TOOL_RESULT的parent_span_id指向对应的TOOL_CALLspan,能够还原“谁发起了这次工具调用”。 - 实时性:Observer 是在 Agent 运行过程中打印的,而不是等任务全部结束后一起输出。后台线程的消费模式保证了这一点。
如果运行失败,先看两处:第一,trace_model 包是否在 Python 路径中,from trace_model.event import ... 是否报 ModuleNotFoundError;第二,time.sleep(2.0) 是否太短导致 Observer 还没来得及消费全部事件,可以把等待时间调大,或者检查 stream._queue.qsize()。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 启动报 ModuleNotFoundError | 项目目录结构不对,或在错误路径下运行 | 检查是否在 trace_demo 目录下执行;检查 __init__.py 是否存在 |
按 trace_demo/trace_model/__init__.py 结构创建文件 |
| Observer 没有打印任何事件 | 后台线程未启动,或事件被阻塞在队列 | 检查 observer.start() 是否执行;打印 queue.qsize() 观察是否积压 |
确认消费线程启动,必要时调大 timeout |
| 事件顺序和 Agent 执行顺序不一致 | 多线程 emit 时 seq 分配出现交叉 | 查看事件中的 seq 字段 |
Parser 侧按 seq 排序,不要依赖到达顺序 |
| 队列积压导致 Agent 变慢 | Observer 消费速度低于事件产生速度 | 查看队列 qsize() 变化曲线 |
扩容消费者组、增大 maxsize、必要时降采样 |
| 错误没有被统计到 | ERROR 事件未带统一的事件类型 | 打印 Parser 的 summary 查看 error_count | 确认业务代码 emit 时使用 TraceEventType.ERROR |
| 敏感信息出现在 trace 里 | payload 直接塞了完整上下文 | 审查 payload 内容的脱敏策略 | 在发射前做字段过滤或脱敏 |
这里最容易被忽视的是第三个问题:队列本身是先进先出的,但多个线程并发 emit 时,事件进入队列的顺序可能和 seq 分配顺序不一致。千万不要在 Observer 侧以“到达顺序”理解事件顺序,所有排序都交给 Parser 按 seq 处理。
9. 工程实践建议与后续学习方向
9.1 采样策略
长周期 Agent 的事件量不会像互联网请求那样爆炸,但因为单次任务持续时间长,事件总量依然可观。生产环境建议按 trace 维度采样,而不是按事件采样:要么整条 trace 都记录,要么整条都丢弃。否则会出现“事件断开、调用树断裂”的情况,可观测性等于没做。
9.2 敏感信息脱敏
追踪流里通常会有工具入参、LLM 的 prompt、状态字段等内容,这些很容易夹带业务敏感数据。有一个原则值得记住:发射端负责脱敏,而不是消费端。 因为消费端可能有多个,每个都脱敏意味着逻辑复制多份。正确做法是在 TraceEmitter.emit 之前做一层 payload 清洗,去掉不能出 Agent 进程的字段。
9.3 与 OpenTelemetry 的集成思路
Live Trace Model 和 OpenTelemetry 的 Trace 概念有很多重叠,但定位不同。本文的模型更关注 Agent 语义,而 OpenTelemetry 是通用可观测性标准。实际项目中,可以在事件模型上再做一层适配,把 tool_call 映射到 OTEL Span,把 llm_generate 映射到带 attributes 的 Span,然后导出到 Jaeger 或 Grafana Tempo。这样既保留 Agent 语义,又复用通用可观测性基础设施。
9.4 Observer 侧的消费语义
Observer 消费流时,要明确一个选择:是启用“消费确认”,还是“游标偏移”。内存队列的方式天然没有 ack 机制,事件一旦 get 出来就没了。这在演示中没有问题,但生产环境如果要做告警确认、离线回放,就需要 Redis Streams 的消费组或 Kafka 的 offset 机制。设计 Observer 时,把“实时消费”和“事后回放”分成两条链路会清晰很多。
9.5 后续可以继续深入的方向
跑通这个最小实现之后,接下来有几个方向值得继续深入:
- 把 TraceEmitter 接入真实 Agent 框架,替换模拟 Agent 的执行循环。
- 设计可视化界面,把 Parser 输出的 JSON Lines 渲染成推理调用树。
- 基于 trace 做自动评测,比较不同模型版本在错误分布和 token 成本上的差异。
- 把实时告警接到企业微信或钉钉机器人,让错误能被及时感知。
这篇文章从问题出发,先解释了 Long-Horizon Agent 为什么需要 Live Trace Model,再完整实现了事件模型、实时流、发射器、解析器和观察者。你只要跟着代码把项目跑一遍,就拥有了一个与框架无关的 Agent 可观测性基础底座。下一步,就是把它放到你的真实 Agent 任务里,让每一次长周期运行都变得可解析、可回放、可告警。
