TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end)解决模型通道的 Key 问题;但更头疼的是多智能体场景:用户退款三天没到账,你翻遍日志,不知是接待 Agent 没传给退款 Agent,还是退款 Agent 调支付工具传错参数,两小时定位不到断链。根子在日志没有统一 schema、请求没有全局 trace_id。要根治,一边按后文规范补结构化日志,一边让 Codex 按 trace_id、span_id、session_id 查断链,Codex 的 Base URL 同样走 TaoToken 提供的 https://taotoken.net/api。多智能体系统的问题排查,慢就慢在「日志之间没有关系」:没有父子 ID,没有角色字段,没有工具调用编号,搜索只能模糊匹配。下面先把字段规范和链路扩展讲透,再直接落到 Codex 配置和一次真实复盘。
1. 先复现「日志串不起来」的现场:print 与模糊搜索的极限
1.1 多智能体的动态链路,微服务那套日志规则兜不住
多智能体系统的主流程看起来简单:用户请求进入,主 Agent 拆任务,要调物流、支付工具就调工具,要把子任务交给退款、查询 Agent 就发协作消息,协作 Agent 执行完再回传,最后由主 Agent 汇总。问题是这条链路不是代码里写死的,而是大模型每次按上下文现决定的:这轮请求调工具,下轮请求可能改派 Agent,同一用户会话里两个不相关的子任务还会异步交错。微服务那套「服务名 + 接口名 + trace_id」的日志模板,套到这里缺了三块:第一,日志里没有 Agent 角色和执行状态;第二,没有工具调用 ID,调了哪个工具、传了什么参数查不到;第三,普通链路追踪的生命周期只有几秒,多智能体一个任务可能跨越好几个用户请求。日志一旦散落在不同 Agent 的进程里,搜索就只能靠模糊匹配,漏检率自然高。
1.2 一次「看起来全对」的搜索,为什么定位不到断链
先看一个典型现场。某个多智能体客服系统里,退款 Agent 处理用户请求时,进程里留下了下面三行日志:
2025-06-11 10:01:23 INFO 收到退款请求,订单号 ord_88231 2025-06-11 10:01:25 ERROR 调用支付接口失败:user_id 格式错误 2025-06-11 10:01:26 INFO 返回给用户:退款成功同一进程里按时间排序,这三行日志出现在相邻位置,肉眼很容易误以为是一次完整请求:收到请求、调用失败、返回成功。实际上这三行分别属于两个不同的用户请求:第一行是 A 用户的退款单,第二行是 B 用户的退款单,第三行是 A 用户的一次查询响应。没有 trace_id 和 session_id,日志系统只能按时间戳排序展示,同一次请求的日志被其他请求的日志切得支离破碎。你花两小时翻日志,大部分时间不是在找问题,而是在排除「这些日志到底是不是同一次请求」。
2. 结构化日志五类字段:让每条日志自带定位坐标
2.1 五类字段的分工
要让 Codex 这类工具能直接读日志定位,日志得先变成「一行 JSON 一条记录」的结构化格式,并且字段分五类:
| 字段类别 | 作用 | 必填字段示例 |
|---|---|---|
| 全局基础字段 | 串联一次请求的完整链路 | trace_id、span_id、parent_span_id、session_id、timestamp、service_name、env、level |
| Agent 专属字段 | 定位是哪个 Agent、哪个阶段出问题 | agent_id、agent_role、agent_version、agent_state、input_token_count、output_token_count、total_cost |
| 交互字段 | 还原 Agent 间消息传递 | sender_type、sender_id、receiver_type、receiver_id、message_type、content_digest、message_status |
| 工具调用字段 | 定位是哪次工具调用失败 | tool_name、tool_version、tool_call_id、tool_input_params、tool_output_digest、tool_duration、tool_error_code |
| 业务扩展字段 | 按业务维度过滤 | order_id、ticket_id 等,按场景自定义 |
trace_id 是整个链路唯一的根标识,16 位十六进制字符串;span_id 是当前这一跳的标识;parent_span_id 指向父级,有了它才能把散落的日志按树形结构归位。session_id 解决长会话问题,同一个用户跨多次请求的 trace 通过 session_id 关联起来。这三者是 Codex 查日志时优先读取的坐标。agent_id 和 tool_call_id 则是定位断链的二级坐标:出问题时先看是「哪个 Agent 的哪一跳」出了错,再看「调了哪个工具的哪个参数」不对。
2.2 一条符合规范的 JSON 日志长什么样
字段命名统一用蛇形命名;时间戳统一毫秒级 Unix 时间戳;枚举值全部大写、下划线分隔;敏感信息脱敏,大模型输入输出只存摘要。下面这条是退款场景里接待 Agent 向退款 Agent 发协作消息时的日志:
{"timestamp": 1717234567890, "level": "info", "trace_id": "9f2c7d1e4a8b3f05", "span_id": "3a1b9c2d7e4f8016", "parent_span_id": "c8d2e6f4a1b93075", "session_id": "sess_8d21f7aa", "service_name": "agent-platform", "env": "prod", "agent_id": "agent_reception_07", "agent_role": "reception", "agent_state": "executing", "input_token_count": 1200, "output_token_count": 300, "sender_type": "agent", "sender_id": "agent_reception_07", "receiver_type": "agent", "receiver_id": "agent_refund_03", "message_type": "AGENT_COMMUNICATION", "content_digest": "sha256:9b2f...", "message_status": "sent"}这条日志既包含当前 Agent 的执行信息,也包含「发给谁、发了什么类型的消息、消息摘要是什么」。后续任何一步出错,都可以先按 trace_id 搜出整条链路,再按 span_id 和 parent_span_id 把顺序排出来,最后看 tool_error_code 和 message_status 找到断点。Codex 拿到这样的 JSON,能直接按字段做结构化分析,而不是在一堆自由文本里做语义猜测。
3. 链路追踪三件套:Span 类型、上下文传播、动态采样
3.1 五种 Span 类型与两次关键传播
给 OpenTelemetry 做多智能体扩展,核心是定义五种 Span 类型,让每一种动作都有专属的追踪节点:
| Span 类型 | 触发时机 | 排查作用 |
|---|---|---|
| ROOT | 用户请求进入接入层 | 一次服务的完整边界 |
| AGENT_EXECUTE | Agent 开始处理任务 | 定位是哪个 Agent 耗时、出错 |
| TOOL_CALL | Agent 调用工具 | 定位工具名、入参、错误码 |
| AGENT_COMM | Agent 向其他 Agent 发消息 | 还原协作消息走向 |
| MEMORY_OP | Agent 读写记忆存储 | 发现上下文丢失或记忆污染 |
上下文传播是链路不散的关键。Agent 间发消息,在消息头里带 traceparent 字段,遵循 W3C Trace Context 规范,这样接收方 Agent 能提取出同一个 trace_id,把新创建的 span 挂到父 span 下面。工具调用则在参数里加一个 trace_context 字段,工具执行完把耗时、错误码写回 span。记忆存储写入时同时关联 trace_id 和 session_id,这样即使几个小时后另一个 Agent 读记忆,也能把这次操作归到原链路里。
3.2 动态采样:错误全量留,普通日志按权重折减
日志量过大时不能全量采,否则存储成本压不住。动态采样率可以按这样一个思路设计:采样率 = 基础采样率 × 错误率权重 × 成本权重 × Agent 优先级权重 × 用户优先级权重。错误率越高权重越大,当某 Agent 最近十分钟内错误率达到阈值时,权重上限可以拉到十倍,也就是从 10% 的采样率直接升到 100% 全采。成本高的请求、核心 Agent(主 Agent、支付相关 Agent)、VIP 用户也都给更高权重。这里有一条不可妥协的规则:不管采样率怎么调,error 级别日志永远 100% 全量保留,因为排障主要靠的就是错误日志。链路完整性也可以用「实际采集 span 数 / 理论应有 span 数」来评估,低于 80% 就说明上下文传播断了,需要检查消息头是否被过滤、工具参数里的 trace_context 是否被丢弃。
4. 落地代码:BaseAgent 基类与 tool_call 装饰器
4.1 先把日志格式化成 JSON,并把 trace_id 注入进去
在代码层面做两件基础的事:日志 JSON 化,以及从当前 OpenTelemetry span 自动取出 trace_id、span_id 写入日志。下面这个自定义 Formatter 在每次写日志时自动带上链路坐标:
import json, time, logging from contextvars import ContextVar from opentelemetry import trace session_id_var: ContextVar[str] = ContextVar("session_id", default="") agent_id_var: ContextVar[str] = ContextVar("agent_id", default="") class TraceJsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: span = trace.get_current_span() span_ctx = span.get_span_context() payload = { "timestamp": int(time.time() * 1000), "level": record.levelname.lower(), "message": record.getMessage(), "trace_id": format(span_ctx.trace_id, "016x") if span_ctx.trace_flags else "", "span_id": format(span_ctx.span_id, "016x") if span_ctx.trace_flags else "", "session_id": session_id_var.get(), "agent_id": agent_id_var.get(), } return json.dumps(payload, ensure_ascii=False)注意这里通过 ContextVar 存放当前请求的 session_id 和 agent_id,日志输出时自动带上,业务代码不用每行手动传。基类 BaseAgent 在执行入口创建一个 AGENT_EXECUTE span,并解析上一跳传来的 trace 上下文,这样不管 Agent 被谁调用,链路都能接上:
class BaseAgent: def run(self, user_input: dict, carrier: dict | None = None) -> dict: ctx = extract_trace_context(carrier) if carrier else None with tracer.start_as_current_span( name=f"{self.role}.execute", context=ctx, attributes={"agent.id": self.agent_id, "agent.role": self.role}, ): return self._execute(user_input)4.2 工具调用装饰器:把每次调用变成独立 span
工具调用类的问题最容易用 span 暴露:传参错了、工具报错了、结果被 Agent 吞了,这三种情况的特征完全不同。用一个装饰器包住所有工具函数,每次调用自动创建 TOOL_CALL span,记录工具名、入参摘要、耗时、错误码:
def tool_call(tool_name: str): def deco(fn): def wrapper(*args, **kwargs): with trace.get_tracer(__name__).start_as_current_span( name=f"tool.{tool_name}", attributes={ "tool.name": tool_name, "tool.input": str({k: mask(v) for k, v in kwargs.items()})[:200], }, ) as span: try: result = fn(*args, **kwargs) span.set_attribute("tool.output_digest", sha256(str(result)[:200])) return result except Exception as exc: span.record_exception(exc) span.set_attribute("tool.error_code", getattr(exc, "code", -1)) raise return wrapper return deco每次工具调用都会在链路里留下一个子 span,入参先脱敏再记录,输出只存摘要。这样排查「到底是工具返回错误、还是 Agent 把错误吞了」时,从 TOOL_CALL span 的 error_code 就能直接判断,而不用靠猜。到这里,日志规范和链路追踪的代码基础就齐了。接下来是文章里最关键的一跃:已经散落的存量日志,怎么快速查出 trace_id 并定位断链?答案是让 Codex 来读。
5. 让 Codex 查 trace_id:Codex 的 TaoToken 接入与排障工作流
5.1 创建 Key:先到 TaoToken 官网注册并建一个
要让 Codex 跑起来,需要一个可用的 API Key 和模型通道。这里推荐从 TaoToken 创建,因为它提供统一兼容通道,一个 Key 可以复用多种模型;模型 ID 以 TaoToken 模型广场当时列表为准,不要盲写网上流传的旧 ID。注册、创建 Key、看用量都在官网完成:打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end → 注册登录 → 控制台 API Keys 创建 YOUR_API_KEY → 记下目标模型 ID(你打算用哪个模型来跑 Codex,就去模型广场确认那个 ID)。
5.2 修改 ~/.codex/config.toml,Base URL 指向 TaoToken
Codex 的接入不靠 ANTHROPIC_* 环境变量,而是改 Codex 自己的配置文件。编辑~/.codex/config.toml:
model = "你的模型ID" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY"保存后在终端导出 Key:
export TAOTOKEN_API_KEY=YOUR_API_KEY这里有两个容易踩的坑:一是 Base URL 是 https://taotoken.net/api,末尾不加 /v1,也不要顺手带上网页的 UTM 参数;二是模型 ID 别在网上复制一段过期的,在 TaoToken 模型广场确认当前可用的 ID 再填。配置保存后,先随便问 Codex 一个问题,确认能正常返回,再进入排障工作流。
5.3 把日志片段和 trace_id 交给 Codex 的具体问法
Codex 排障的正确姿势是:把结构化日志片段直接贴给它,而不是丢一句「帮我看看日志哪里错了」。推荐的提问框架是:
- 先给出本次请求的坐标:
trace_id=9f2c7d1e4a8b3f05、session_id=sess_8d21f7aa。 - 把按 trace_id 聚合后的日志片段(或日志文件路径)给 Codex,并说明字段含义:
span_id是当前跳,parent_span_id是父跳,tool_error_code非 0 表示工具调用失败,message_status=failed表示 Agent 间消息没送达。 - 让 Codex 按
parent_span_id画出调用树,标出哪些 span 有父无子、哪些 TOOL_CALL 的 error_code 非 0。
示例问法:
这是同一次退款请求的完整日志 JSON,trace_id 是 9f2c7d1e4a8b3f05。 请按 parent_span_id 还原调用链,找出哪个 AGENT_EXECUTE 或 TOOL_CALL span 异常中断, 并对比 AGENT_COMM 的 receiver_id 和后续 span 的 agent_id,看消息是否真的送到。Codex 会从 JSON 里提取 span 关系、按时间戳和父子 ID 还原出「接待 Agent → 退款 Agent → 支付工具」的完整链路,标出断点。如果日志量太大,可以让它在本地日志目录里执行grep先过滤出该 trace_id 的所有行,再分析。注意 Codex 只读取你给出的日志文件和片段;它生成的 SQL 脚本由你在本地数据库执行,再把结果贴回对话,不要让它直连生产库做诊断执行。
6. 一次退款断链复盘:从 trace_id 到错误参数只花几分钟
6.1 断链链路还原
回到开头的退款问题。有了结构化日志后,客服拿到用户会话 IDsess_8d21f7aa,在日志系统里搜这个 session_id,过滤出所有 error 日志,得到 trace_id9f2c7d1e4a8b3f05。把这批日志贴给 Codex 后,它按parent_span_id还原出的链路是这样的:
- ROOT span:用户发起退款请求,进入客服系统。
- AGENT_EXECUTE:接待 Agent 受理,规划后向退款 Agent 发出 AGENT_COMM 消息。
- AGENT_EXECUTE:退款 Agent 收到消息,提取 user_id 时取错了字段,把订单号当作 user_id 放进工具入参。
- TOOL_CALL:调用支付工具,
tool_error_code为 400,入参摘要里user_id的值与订单号前缀完全一致。 - 后续没有生成 TOOL_RESPONSE 对应的处理 span,退款 Agent 捕获异常后没有继续上报,直接返回「退款成功」。
到这里,断链原因已经从「哪个环节出了错」精确定位到「退款 Agent 提取参数时用错字段」。整个排障过程从原来翻两小时日志,缩短到几分钟。这里的关键不是 Codex 有多聪明,而是日志里有 trace_id、span_id、parent_span_id、tool_error_code 这些结构化坐标,它才能按图索骥;日志如果不结构化,再强的模型也只能靠猜。
6.2 排障边界与几条必须守住的底线
这个工作流能跑通,依赖几条纪律:第一,error 日志 100% 采样,任何采样策略都不许丢错误日志;第二,敏感信息先脱敏再落盘,手机号、账号、模型明文输出只存摘要,这样把日志贴给 Codex 时不用担心泄露;第三,存储分层,普通日志存短周期、错误日志和链路数据存更长时间,按自己团队的存储成本定。另一条边界是:Codex 只做日志和代码层面的分析,涉及数据库诊断时,SQL 由它生成、你在本地执行,再把结果贴回对话;生产环境的变更操作更是只能由人来执行。这一步做完,如果还没创建过 Key,现在去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 补上也不迟,后面每次排障都会用上同一条 API 通道。
跑完上面这轮排障,可以回 TaoToken 控制台对一下这次调用是否正常入账:先用同一把 Key 在 模型对话 发一条测试消息,确认模型 ID 和 Base URL 没填错;Key 不够了就在 控制台 API Keys 新建;如果计划把 Codex 长期当排障助手用,可以看看 Coding Plan 的套餐是否更划算。