新人 免费注册,送 10 次调用,最高 $1,无需绑卡。

只用 3 个 HTTP 请求头追踪 LLM 智能体,无需追踪 SDK

为智能体发出的模型调用添加三个请求头,即可查看每次运行的瀑布图,了解并行调用、自有代码执行耗时、失败调用和费用。瀑布图将模型请求与自身代码的耗时放在同一次运行中呈现,帮助区分哪些调用并行发生、时间花在何处、哪些请求失败,并查看该次运行的调用成本。

我们的推荐

实时价格
模型 结论 价格
GPT-6 Luna 处理演示中的函数调用循环。在本页展示的运行中,每轮的两次调用(规划和回答)合计花费 $0.0002。 演示中的主智能体 低至 $0.1/百万
DeepSeek V4.1 Flash 在关闭推理的情况下,并行执行三项单句检查。在本页展示的会话中,它的六次调用花费 $0.0004,第七次子智能体调用失败,花费 $0。 演示中的子智能体 低至 $0.3/百万
目录
  1. 生成追踪记录需要发送什么?
  2. 示例智能体做什么?
  3. 如何看瀑布图?
  4. 会话比单条追踪多提供什么?
  5. 如何按功能或用户拆分费用?
  6. 调用失败时会怎样?
  7. 如何在代码中获取每次调用的费用?
  8. 智能体已经发送追踪请求头怎么办?
  9. 有了追踪记录之后做什么?
  10. 示例没有覆盖什么?
  11. 常见问题
  12. 追踪 LLM 智能体需要 OpenTelemetry 吗?
  13. 追踪和会话有什么区别?
  14. 追踪请求头会改变路由、计费或模型看到的内容吗?
  15. 追踪记录保留多久?
  16. 失败调用会收费吗?
  17. 相关阅读

一个智能体为了回复一条消息要调用模型五次,仅靠平铺的请求日志很难排查问题:哪些调用属于同一次运行,哪些并行执行,等待时间有多少花在自己的代码里,都看不出来。本文给一个小型客服智能体加上三个请求头,然后在 Synthorai 控制台中查看追踪记录(用时间线展示一次运行)和会话(一次对话及其费用)。不需要安装追踪 SDK,也不需要运行 collector(单独接收追踪数据的服务)。源码位于 github.com/synthorai-io/use-cases 的 agent-tracing/ 目录。运行需要 Python 3.10 或更高版本,以及从控制台获取的 API key。

TL;DR

  • 每次模型调用带上三个请求头(X-Trace-Id、X-Span-Id、X-Session-Id),Synthorai 控制台就能把请求日志组织成追踪记录和会话,无需追踪 SDK。
  • 示例追踪中,8.5 s 的总耗时分为约 7.0 s 的模型调用时间,以及约 1.6 s 的调用外耗时,后者包括智能体自身代码和网络耗时。
  • 一次返回 404 的失败调用仍保留在追踪记录中,费用为 $0。
  • 示例共发起 21 次模型调用,费用为 $0.0017。
git clone https://github.com/synthorai-io/use-cases
cd use-cases
cp .env.example .env    # put your API key in it
cd agent-tracing
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python run.py --fail

我们在 2026 年 10 月 11 日运行这个示例,不到一分钟完成了 21 次模型调用,费用为 $0.0017。程序会打印两个会话 ID 和四个追踪 ID。打开控制台的追踪列表,就能找到这些 ID。控制台按浏览器所在时区显示时间,所以本文截图中的 19:24 对应 UTC 时间 11:24。

控制台中的一条追踪记录:总耗时 8.5 s,其中网关耗时 7.0 s,应用耗时 1.6 s;6 次模型调用,1 次失败。下方瀑布图依次显示 plan 调用、本地工具执行期间的空档、三个重叠的子智能体调用、标记为 404 的 95 ms 调用,以及 answer 调用。右侧面板显示失败调用详情:95 ms、费用 $0、未扣费,以及错误文本

生成追踪记录需要发送什么?

每次模型调用发送三个请求头,值由你自行指定,都是字符串。网关本来就会记录每个请求,这些请求头只是告诉它哪些请求属于同一组。

请求头哪些调用使用相同的值得到什么
X-Trace-Id一次运行中的所有调用(从头到尾处理一条用户消息)将这些调用画在同一张瀑布图中
X-Span-Id仅用于一次调用该调用在瀑布图中的行名称
X-Session-Id一次对话中的所有调用按会话归集运行记录,并汇总整段对话的数据

span 是追踪中的一个步骤。本文中,每个 span 对应一次模型调用。每个 ID 最长为 128 个可打印 ASCII 字符。网关会在响应中返回这些 ID,但不会将它们转发给模型提供商。

还有三个可选请求头。X-Agent-Id 标明调用来自哪个子智能体,X-Parent-Agent-Id 标明哪个智能体启动了这个子智能体,X-Synthorai-Metadata 则携带功能、最终用户等标签。请求头参考列出了全部请求头。示例发送 X-Agent-Id 和标签,不发送 X-Parent-Agent-Id:唯一的子智能体由主智能体启动,没有需要描述的嵌套关系。

示例用一个函数为调用构造请求头,这个函数就是整个追踪层:

@dataclass
class Run:
    """One agent run, which the gateway shows as one trace."""
    session_id: str
    labels: dict = field(default_factory=dict)
    trace_id: str = field(default_factory=lambda: new_id("trace"))

    def headers(self, span: str, agent: str | None = None, parent: str | None = None) -> dict:
        h = {
            "X-Session-Id": self.session_id,
            "X-Trace-Id": self.trace_id,
            "X-Span-Id": span,
        }
        if agent:
            # Only sub-agents need an id. Calls without one belong to the main agent.
            h["X-Agent-Id"] = agent
        if parent:
            h["X-Parent-Agent-Id"] = parent
        if self.labels:
            h["X-Synthorai-Metadata"] = json.dumps(self.labels, separators=(",", ":"))
        return h

智能体的每次模型调用都经过同一个封装函数,由它附加这些请求头。使用 OpenAI Python SDK 时,单次请求的请求头放在 extra_headers 中:

raw = await client.chat.completions.with_raw_response.create(
    model=model, messages=messages,
    extra_headers=run.headers(span, agent, parent), **kwargs)
resp = raw.parse()
request_id = raw.headers.get("x-request-id")   # the gateway's id for this call

这里使用 with_raw_response 只是为了读取响应头。如果不需要请求 ID,直接调用 create(..., extra_headers=...) 即可。

示例智能体做什么?

它分四个阶段、通过五次模型调用回复一条客户消息。这样设计是为了让追踪图有足够的内容可看。主智能体使用 GPT-6 Luna,子智能体使用 DeepSeek V4.1 Flash,两者都通过环境变量配置。子智能体调用设置了 reasoning_effort="none",因为一句话的检查不需要模型先做推理。如果换成不接受这个值的模型,需要修改或清空 ANALYST_REASONING。

Span执行方执行内容
plan主智能体读取消息并请求调用工具(function calling:模型返回函数名和参数,由你的代码执行)
无你的代码在本地并行执行两个工具:订单查询(1.2 s)和政策搜索(0.6 s)
sentiment、policy-check、risk子智能体 analyst同时发起三个检查,每个检查返回一句话
answer主智能体根据工具结果和三个检查结果生成回复

工具只是带有 sleep 的普通 Python 函数,用来模拟数据库和搜索索引。启用 --fail 后,一次 policy-check 调用会先请求一个不存在的模型,收到 404,再以 policy-check.retry 作为 span 名称重试。

run.py 模拟两段对话,每段两轮。每段对话使用一个会话 ID,每轮生成一个新的追踪 ID:

session_id, history = new_id("support"), []
for text in turns:
    run = Run(session_id, labels={"feature": "support-agent", "release": "v2", "_user": user})
    reply = await handle(run, history, text)

如何看瀑布图?

每一行代表一次模型调用,或你的代码执行期间的一段空档。时间轴从本次运行的第一个调用到达网关时开始。上面的截图是第一段对话的第二轮。从上往下看:

  • plan 耗时 2.3 s。条形长度表示网关处理该请求的时间,从收到请求到完成响应。
  • your app 耗时 1.4 s。这段时间没有正在处理的请求,因此耗时发生在网关之外,包括你的代码,以及代码与网关之间的网络通信。这里执行的是两个本地工具,较慢的那个会休眠 1.2 s。在实际智能体中,慢数据库查询或向量搜索会在这一行显现出来。
  • sentiment、risk 和 policy-check.retry 相互重叠。智能体通过 asyncio.gather 同时发送三个检查,重叠的条形表示并行调用。名称前的箭头表示调用由子智能体发起。
  • policy-check 是一个耗时 95 ms、标有 404 的斜线填充标记。失败调用会留在追踪记录中,并显示你的代码收到的状态码。
  • answer 耗时 3.0 s,是最慢的一次调用,瀑布图上方的摘要也会标明这一点。
  • Output 列显示每次调用的输出 token 数。调用耗时主要花在生成输出上,所以这一列通常能解释旁边条形的长度:answer 输出了 101 个 token,三个检查各输出 14 到 57 个。较长输出中如果大部分是推理内容,控制台会给数量加下划线,这里的 101 就是如此。

摘要把 8.5 s 的总耗时分为网关内的 7.0 s 和应用侧的 1.6 s。这个划分能回答延迟排查中最先要确定的问题:应该查模型调用,还是查自己的代码。本次运行中,82% 的等待时间花在模型上,主智能体的 plan 和 answer 两次调用占了其中的 5.3 s。因此,给主智能体换一个更快的模型,比加快三个检查更有效。

网关看不到你的代码内部。它根据请求之间的空档推断 your app 行,所以空档只能告诉你代码运行了多久,不能告诉你做了什么。如果需要为自己的函数记录 span,就要使用开放追踪标准 OpenTelemetry 的 SDK。两者可以共用 ID:未发送 X-Trace-Id 时,网关会读取携带追踪 ID 的标准请求头 traceparent。

会话比单条追踪多提供什么?

会话把一段对话中的各次运行归集起来,并汇总数据。可以从追踪列表打开会话,也可以在任意追踪页面点击“打开会话”。

控制台中的一个会话:11 次调用,费用 $0.0008,包含一次 404;带有 _user、feature 和 release 标签;智能体表格显示主对话使用 gpt-6-luna,analyst 子智能体使用 deepseek-v4.1-flash;下方列出会话的两条追踪记录及其耗时、输入、缓存命中和费用

上面的会话是一段两轮的客户对话:11 次调用,1,890 个输入 token,750 个输出 token,费用 $0.0008,1 个错误。页面分为三部分:

  • 本会话中的智能体 按智能体拆分调用和费用。主智能体调用 GPT-6 Luna 4 次,费用 $0.0004;analyst 子智能体调用 7 次,费用 $0.0004,其中 6 次使用 DeepSeek V4.1 Flash,另一次是失败调用。子智能体调用携带了 X-Agent-Id: analyst,因此页面显示这张表。没有这个请求头的调用计入主对话。
  • 本会话中的追踪记录 列出每轮的错误、耗时、输入 token、缓存命中和费用。无需翻阅日志,就能在长对话中找到慢、失败或费用高的那一轮。
  • 缓存命中 表示从提供商 prompt cache 读取的输入 token 比例。这里为 0%,因为 prompt 只有几百个 token,而提供商只缓存超过最小长度的前缀(多数提供商要求 1,024 个 token,参见 prompt 缓存)。

当 prompt 长到足以缓存时,会话 ID 还有另一个重要用途。同一段对话的每次调用都发送相同的 X-Session-Id:网关用它保持该对话的 prompt cache 命中一致,让第二轮能读取第一轮写入的缓存。对于 /v1/chat/completions 和 /v1/responses,如果框架不方便设置请求头,也可以把值放在请求体的 session_id 中。

如何按功能或用户拆分费用?

通过 X-Synthorai-Metadata 发送一个扁平 JSON 对象,值均为字符串,然后按这些字段分组或筛选。示例为每次调用标记功能、版本和最终用户:

labels = {"feature": "support-agent", "release": "v2", "_user": "u_1002"}

这些标签会以小标签的形式出现在追踪和会话页面上。要看汇总,打开分析页面,选择明细标签页,找到“按元数据统计费用”卡片。选择一个键,卡片会列出该键的所有值,以及对应的请求数、错误数、输入 token 和费用:

“按元数据统计费用”卡片,选中了 _user 键:u_1001 有 22 个请求、2 个错误,费用 $0.0018;u_1002 有 20 个请求、0 个错误,费用 $0.0017

上面的卡片覆盖一个小时,期间示例运行了两次:u_1001 有 22 个请求,费用 $0.0018,其中包含两次失败调用;u_1002 有 20 个请求,费用 $0.0017。只运行一次后,看到的请求数会是 11 和 10。选择 feature 可查看各功能的费用,选择 release 可查看 prompt 改动对费用的影响。卡片还支持并排比较同一个键的两个值。同一标签页中还有“按智能体统计费用”卡片,跨会话汇总每个 X-Agent-Id 的费用。

要找到某个统计数字对应的运行,可以用标签搜索。在追踪列表的搜索框输入 metadata._user = u_1002,按 Enter,就只会保留该用户的会话和追踪记录。日志页面的筛选框也支持同样的表达式,用于筛选单个请求。

标签规则很简单:

  • 每个请求最多 10 个标签。键长为 1 到 32 个字符,只能使用 a-z、0-9、_、. 和 -。值是最长 128 字节的字符串。
  • _user 用来标记请求所服务的最终用户。其他以下划线开头的键均为保留键。
  • 不符合规则的标签会被丢弃,请求仍会执行。响应头 X-Synthorai-Metadata-Dropped 会列出被丢弃的键,配置时应记录这个响应头。我们发送了一个名为 Bad?Key 的键做验证,返回的请求头中确实包含 Bad?Key。
  • 不要把 prompt 文本、个人数据或密钥放进标签。_user 使用内部用户 ID,不要使用邮箱地址。

调用失败时会怎样?

失败调用会以带状态码的斜线填充标记留在追踪中,不产生费用。示例中,失败的 policy-check 请求了一个不存在的模型 ID。在瀑布图中选中它,就会打开上方截图右侧的面板,显示代码收到的错误:The model "model-that-does-not-exist" does not exist or is not available to this workspace,以及 95 ms 的延迟、$0 的费用和未扣费说明。

示例为重试单独设置 span 名称:

try:
    await call(run, span, model=BROKEN_MODEL, messages=messages, agent="analyst")
except APIStatusError:
    span = f"{span}.retry"      # the retry is its own span, so both show in the waterfall
resp = await call(run, span, model=ANALYST_MODEL, messages=messages, agent="analyst", max_tokens=400,
                  **analyst_options())   # reasoning_effort="none" unless ANALYST_REASONING says otherwise

保持追踪 ID 不变、修改 span 名称,就能在同一张图中把失败调用和重试放在一起。如果重试逻辑位于复用相同请求头的库中,两次尝试仍然都会显示,只是两行名称相同。

错误也会汇总。追踪摘要显示“6 次模型调用,1 次失败”,会话按状态码列出错误(1 × 404 Not found),追踪列表还有可排序的错误列,方便找到需要处理的运行。

如何在代码中获取每次调用的费用?

用响应中返回的请求 ID,向网关查询该请求的计费记录。GET /v1/generation?id=<request id> 返回单个请求的费用、token 数、延迟和状态。

async def cost_of(http: httpx.AsyncClient, request_id: str) -> float | None:
    """The billing record of one request. It is written a moment after the call returns."""
    if not request_id:
        return None                                  # a call that got no response has no record
    for _ in range(6):
        try:
            r = await http.get(f"{BASE_URL}/generation", params={"id": request_id})
            if r.status_code == 200:
                return r.json()["data"]["total_cost"]
        except (httpx.HTTPError, KeyError, TypeError, ValueError):
            pass                                     # a dropped connection or a half-written record: try again
        await asyncio.sleep(2)
    return None

记录会在响应完成后稍晚写入,所以函数会重试,直到收到 200。调用方只创建一次 httpx.AsyncClient,设置好 Authorization 请求头,再将它传给每次查询。用同一个客户端查询二十次调用,只需复用少量连接。如果每次查询都新建客户端,就会同时打开二十条连接;我们运行时,其中一两条连接会断开。示例根据自身计时和这些记录,为每次调用打印一行。下面是截图中会话的第一轮:

trace trace-5258a3328d7d  session support-d8ac3cdd4411
  plan                 main     gpt-6-luna                 200   1878 ms    153 in   22 out  $0.000026
  sentiment            analyst  deepseek-v4.1-flash        200   1431 ms     91 in   28 out  $0.000061
  risk                 analyst  deepseek-v4.1-flash        200   1877 ms     96 in   33 out  $0.000068
  policy-check         analyst  deepseek-v4.1-flash        200   1885 ms     96 in   39 out  $0.000076
  answer               main     gpt-6-luna                 200   4142 ms    350 in  339 out  $0.000205

这里的时间在客户端测量,包含网络耗时,所以比控制台中的条形更长;控制台只测量请求在网关内部的耗时。在第二轮,也就是上面的瀑布图中,plan 的客户端耗时为 2,499 ms,网关耗时为 2.3 s;answer 分别为 3,161 ms 和 3.0 s。并行发送的调用差距可能更大:失败调用在客户端耗时 694 ms,在网关中耗时 95 ms。

如果想在自己的日志中记录每次调用的实际计费费用,可以复制这个函数。它不适合用来中止费用过高的运行,因为每条记录都要在调用结束后稍等才可用,函数可能为此等待数秒。实时预算控制应在运行过程中根据每个响应的 usage 自行计算费用,之后再用这些记录核对。

智能体已经发送追踪请求头怎么办?

这种情况下可能无需添加任何内容。如果请求没有携带三个 ID 中的某一个,网关会读取常见工具已发送的对应请求头:

使用的工具发送的请求头读取为
OpenTelemetry HTTP 自动埋点traceparent追踪 ID,其中的父 ID 作为 span
LiteLLM SDKX-Litellm-Trace-Id、X-Litellm-Session-Id追踪 ID、会话 ID
Langfuse SDKbaggage: langfuse_trace_id=…、langfuse_session_id=…追踪 ID、会话 ID
Open WebUIX-OpenWebUI-Chat-Id会话 ID
Claude CodeX-Claude-Code-Session-Id会话 ID
OpenAI Codex CLISession-Id会话 ID

两者同时存在时,你设置的 X-Trace-Id、X-Span-Id 和 X-Session-Id 优先。完整列表见请求头参考。如果智能体框架不发送这些请求头,就需要通过它提供的自定义请求头配置来添加。

有了追踪记录之后做什么?

最常见的用途是与另一条追踪记录比较。每个追踪页面都有比较按钮:选择第二条追踪记录,控制台会在同一页展示两者。每项指标显示 A、B 和变化量,同名调用逐一配对,两张瀑布图放在同一条时间轴上。这样就能检查 prompt、模型或自身代码的改动对运行产生了什么影响。

下面三篇后续指南使用同一个智能体和相同的请求头,按这种方式复现问题、修复问题并比较结果:

另外三篇从其他角度查看同一份日志:

示例没有覆盖什么?

示例覆盖的是模型调用。生产环境通常还会补上四项:

  • 自有代码的 span。网关能显示模型调用之间的空档,但看不到期间做了什么。用 OpenTelemetry 为工具、检索和队列埋点,并传递 traceparent,就能用同一个追踪 ID 关联两边的视图。
  • Prompt 和响应正文。本文使用每次调用的耗时、token 和费用记录,不涉及检查实际发送给模型的内容。
  • 预算。cost_of 提供的是事后计费金额。要在达到限额时停止运行,需要在每个响应到达时根据 usage 计算费用。智能体循环中加几行代码即可,但示例未包含这部分。
  • 采样。每次调用都会被记录,因为追踪直接基于网关原本保留的请求日志构建。无需采样,智能体侧除了几个请求头之外也没有额外开销。

常见问题

追踪 LLM 智能体需要 OpenTelemetry 吗?

不需要。每次模型调用带上三个请求头(X-Trace-Id、X-Span-Id、X-Session-Id),就能得到模型调用瀑布图,调用之间的时间显示为应用耗时。需要为自己的函数记录 span 时,OpenTelemetry 才有用。

追踪和会话有什么区别?

追踪对应智能体的一次运行,包含处理一条用户消息或一个任务的全部过程。会话对应一段对话,由多次运行组成。示例中,每段客户对话是一个会话,包含两条追踪记录,每条记录有五次或六次模型调用。

追踪请求头会改变路由、计费或模型看到的内容吗?

X-Trace-Id 和 X-Span-Id 不会改变这些行为。X-Session-Id 用于保持一段对话的 prompt cache 命中一致。这些请求头都不会转发给模型提供商,计费使用的是网关自身的请求 ID。

追踪记录保留多久?

追踪和会话基于请求日志构建,同样覆盖 90 天。追踪列表默认显示最近 7 天。

失败调用会收费吗?

示例中的失败调用因模型 ID 不存在而返回 404,记录费用为 $0,并注明未扣费。

相关阅读

← 使用场景