跳转至

agent-cost-analysis

第6章 · Agent 的评估 · 配套项目 chapter6/agent-cost-analysis

项目说明

实验 6-7:Agent 任务的端到端成本分析

配套《深入理解 AI Agent》第 6 章「实验 6-7 ★:Agent 任务的端到端成本分析」。

对一个典型的多轮 Agent 任务(客服退款)做全链路成本拆解,用自建的轻量 tracing / 可观测系统记录每次 LLM 调用的输入/输出/缓存 token、时延与成本:按步骤聚合出「哪一步最贵」,按成本构成拆出「未缓存输入 / 缓存输入 / 输出各占多少、工具返回注入了多少 token」,并给出单步成本分布(p50/p95/p99);再做完整 2×2 A/B 对比,量化 KV-cache 复用上下文压缩 两个杠杆各自及叠加后的真实成本差异。

  • 默认模型 gpt-5.6-luna(当前廉价旗舰),通过 openai Python SDK 调用。首选 OPENAI_API_KEY;未设置时自动回退到 OPENROUTER_API_KEY(走 OpenRouter 兼容端点,gpt-* 映射为 openai/*)。由于 gpt-5.x 直连 OpenAI 需组织实名认证,只要存在 OPENROUTER_API_KEY 就优先走 OpenRouter。
  • KV-cache 的节省是真实的:利用 OpenAI 的自动 prompt caching(前缀 ≥ 1024 token 且命中近期相同前缀时,usage.prompt_tokens_details.cached_tokens > 0,这部分输入按缓存价 5 折计费)。
  • 提供离线模式:不打模型,读入一份此前真实运行录下的 token 用量(sample_trace.json,canned token counts),用可配置单价重新计算成本/成本构成/A/B 对比表——无需 API key 即可复现全部表格,也可一键换算到其它模型定价。

文件

文件 说明
config.py 模型与价格:Pricing 单价对象 + 常见 OpenAI 模型单价预设,token→成本换算
tracer.py 自建轻量 tracing:包裹每次 LLM 调用记录 token/缓存/时延/成本;成本构成拆解、单步成本分布、按步骤拆解表;支持从录制用量离线复算(from_records
agent.py 多轮客服退款 Agent 任务;run_scenario(kv_cache, compress) 把两个开关正交组合成 2×2 场景,并用 tiktoken 估算「工具返回注入」token
demo.py 命令行入口(argparse):在线跑真实模型 / 离线复算;选择 A/B 场景、模型单价、输出文件
sample_trace.json 一次真实运行录下的四个场景逐步 token 用量(离线模式的输入,成本按当前单价重算)
requirements.txt / env.example 依赖与环境变量示例

运行

pip install -r requirements.txt

# 在线(真实调用模型,需要 key):默认跑 A(朴素)+B(优化) 两组
export OPENAI_API_KEY=sk-...           # 或 export OPENROUTER_API_KEY=sk-or-...(自动回退)
python demo.py

# 离线(无需 key):用内置 canned trace 复算全部表格
python demo.py --offline --scenario all

在线模式会真实调用 OpenAI,--scenario all 约几十次 chat completion,运行一两分钟。

命令行参数(python demo.py --help

参数 说明
--live / --offline 在线真实调用(默认)/ 离线从 trace 文件复算(无需 key)
--scenario NAME ab(默认=naive+both) / all(2×2 四组) / 逗号分隔子集 naive,kv,compress,both
--trace FILE 离线读取的 canned trace,默认 sample_trace.json
--save-trace FILE 在线跑时把真实 token 用量落盘,供之后 --offline 复算
--model NAME 模型名(决定默认单价预设:gpt-4o-mini/gpt-4o/gpt-4.1-mini/gpt-4.1
--price-input/-cached/-output 覆盖三档单价(每百万 token 美元)
--no-warmup 关闭 KV-cache 组的前缀预热(默认预热以稳定命中缓存)
--output FILE 把成本拆解结果(含成本构成/分布/逐步用量)写成 JSON

不改任何参数直接 python demo.py,行为与之前一致:在线跑 A(朴素) 与 B(优化) 两组并打印拆解 + A/B 对比表。

A/B 四种策略(完整 2×2)

同一个 8 轮客服退款任务(查订单 → 查物流 → 查退款政策 → 查知识库 → 风控 → 发起退款 → 通知 → 关单),四组做的是同样的逻辑工作,只在上下文构造上不同——因此成本差异纯粹来自「是否 KV-cache 友好」与「是否压缩上下文」两个正交开关:

场景 KV-cache 压缩 上下文构造
naive A 朴素 每轮 system 前塞随机 session 头(破坏前缀)+ 历史工具返回原样带全
kv 仅缓存 稳定长前缀(system 逐字节不变)+ 历史不压缩
compress 仅压缩 前缀不稳定 + 仅最近 2 轮保留完整工具返回、更早压成一句话摘要
both B 优化 稳定长前缀 + 上下文压缩(两个杠杆叠加)

为聚焦「输入侧」两个杠杆,四组都用 temperature=0 且限制输出长度(max_tokens=160),让输出 token 成本近似为四组相等的固定项,避免模型生成长度的随机波动干扰对比。

工具环境是「受控」的(工具返回内容预设,真实系统里来自订单/物流/知识库后端),但每一次 LLM 调用、每一份 token 用量、每一分成本都是真实打到 OpenAI 得到的,保证可复现。

真实运行输出(gpt-4o-mini)

以下为一次真实运行(python demo.py --scenario all)的输出,sample_trace.json 即由该次运行落盘、供 --offline 复现。

(a) 单次任务成本拆解:按步骤 + 按成本构成 + 分布

===== 成本拆解: A 朴素(无缓存/无压缩)(单次任务全链路拆解) =====
步骤       工具/动作                   输入tok    缓存tok    工具tok    输出tok    时延(s)        成本($)
---------------------------------------------------------------------------------------
turn-1   query_order              1113        0      276      104     3.15     0.000229
turn-2   query_logistics          1807        0      829       99     2.09     0.000330
turn-3   check_refund_policy      2154        0     1046      139     2.69     0.000406
turn-4   query_knowledge_base     2564        0     1287      160     2.92     0.000481
turn-5   query_user_history       2863        0     1389      136     2.69     0.000511
turn-6   issue_refund             3123        0     1490      160     3.07     0.000564
turn-7   send_notification        3408        0     1579      160     3.09     0.000607
turn-8   close_ticket             3668        0     1648      160     2.50     0.000646
---------------------------------------------------------------------------------------
合计                               20700        0     9544     1118    22.20     0.003776

最贵的一步 → turn-8 / close_ticket: $0.000646(占总成本 17.1%)
成本构成:
  未缓存输入     20700 tok  $0.003105  (82.2%)
  缓存输入           0 tok  $0.000000  (0.0%)
  输出            1118 tok  $0.000671  (17.8%)
  其中「工具返回注入」累计输入 9544 tok (同一份工具返回在后续每轮被反复计费)
单步成本分布(n=8): 均值 $0.000472  p50 $0.000481  p95 $0.000646  p99 $0.000646

===== 成本拆解: B 优化(KV缓存+压缩)(单次任务全链路拆解) =====
步骤       工具/动作                   输入tok    缓存tok    工具tok    输出tok    时延(s)        成本($)
---------------------------------------------------------------------------------------
turn-1   query_order              1056     1024      276      139     2.41     0.000165
turn-2   query_logistics          1781        0      829      112     2.18     0.000334
turn-3   check_refund_policy      2143     1024     1046      151     2.56     0.000335
turn-4   query_knowledge_base     2310        0     1052      160     3.03     0.000442
turn-5   query_user_history       2060     1024      635      160     2.48     0.000328
turn-6   issue_refund             2143     1024      551      160     2.71     0.000341
turn-7   send_notification        2188     1024      430      160     2.79     0.000347
turn-8   close_ticket             2354     1024      429      122     2.38     0.000349
---------------------------------------------------------------------------------------
合计                               16035     6144     5248     1164    20.54     0.002643

最贵的一步 → turn-4 / query_knowledge_base: $0.000442(占总成本 16.7%)
成本构成:
  未缓存输入      9891 tok  $0.001484  (56.1%)
  缓存输入        6144 tok  $0.000461  (17.4%)
  输出            1164 tok  $0.000698  (26.4%)
  其中「工具返回注入」累计输入 5248 tok (同一份工具返回在后续每轮被反复计费)
单步成本分布(n=8): 均值 $0.000330  p50 $0.000335  p95 $0.000442  p99 $0.000442

可以看到:朴素组 A 的输入 token 随轮次从 1113 一路涨到 3668(上下文累积效应,最后一步最贵),「工具返回注入」累计吃掉 9544 输入 token;优化组 B 的输入 token 被压缩策略压住(末轮 2354 而非 3668,工具注入累计降到 5248),且多数轮次持续命中 1024 缓存 token,缓存输入把整块费用打了 5 折。

(b) 完整 2×2 A/B 对比

===== A/B 成本对比(同一个 8 轮客服退款任务)=====
方案                             总输入tok      缓存tok      缓存率    输出tok       总成本($)       vs基线
------------------------------------------------------------------------------------------
A 朴素(无缓存/无压缩)                   20700          0     0.0%     1118     0.003776         基线
KV 仅缓存(稳定前缀/不压缩)                20386      13568    66.6%     1112     0.002707     -28.3%
仅压缩(前缀不稳定/摘要)                   16177          0     0.0%     1147     0.003115     -17.5%
B 优化(KV缓存+压缩)                   16035       6144    38.3%     1164     0.002643     -30.0%
------------------------------------------------------------------------------------------

重点对比:A 朴素(无缓存/无压缩)  →  B 优化(KV缓存+压缩)
  总 token:   A=21818  →  B=17199   减少 4619 (21.2%)
  缓存 token: A=0  →  B=6144   (B 靠稳定前缀命中缓存)
  总成本:     A=$0.003776  →  B=$0.002643   降低 $0.001133 (30.0%)
  成本倍率:   A 是 B 的 1.43 倍

结论(读出两个杠杆各自与叠加的贡献): - 仅 KV-cache:不改上下文长度,只靠稳定前缀让重复的系统提示/工具定义/历史轮次按缓存价计费,缓存率冲到 66.6%,端到端成本就降了 28.3%——本例中它是单个最有效的杠杆。 - 仅压缩:把旧轮次工具返回压成摘要,总输入 token 从 20700 降到 16177(约 −22%),端到端成本降 17.5%;但因为前缀不稳定,缓存率为 0。 - 两者叠加(B 优化):输入 token 最少、又能命中缓存,端到端总成本降 30.0%(A 是 B 的 1.43 倍)。单看输入侧成本,降幅落在书中「KV Cache 可降低 30%-60% 输入 token 成本」的经验区间内。

注意 KV 与压缩并非简单相加:压缩缩短了历史,可被缓存的「历史轮次」也随之变少,所以 B 的缓存 token(6144)反而少于「仅 KV」(13568)。这正是评估的价值——两个优化叠加时要实测协同效应,而不是把各自的收益直接相加。

离线复算(无需 API key)

sample_trace.json 存的是上面这次真实运行的逐步 token 用量(实测值);离线模式只做「按单价重算成本」这一步纯离线数学,因此无需 key 即可复现全部表格,还能一键换算到其它模型定价:

python demo.py --offline --scenario all                 # 用 gpt-4o-mini 单价复算
python demo.py --offline --scenario all --model gpt-4o  # 同一份 token 用量,换 gpt-4o 单价重算
python demo.py --offline --price-input 0.20 --price-cached 0.10 --price-output 0.80

gpt-4o 单价后,同一批 token 的四组总成本等比放大(结论与占比不变,A=\(0.062930 → B=\)0.044047,仍是 −30.0%)——这说明成本优化的相对收益由 token 结构决定,与绝对单价无关

注意事项

  • 具体数字每次在线运行会有小幅波动:OpenAI 的 prompt cache 是尽力而为的(按约 128 token 的块缓存、约 5–10 分钟过期、偶尔未命中),因此某些轮次可能出现 cached_tokens=0(如上面 B 组的 turn-2/turn-4)。demo.py 在正式计量前会先对 KV-cache 组跑一次「预热」把稳定前缀写入缓存,让命中更稳定;--no-warmup 可关闭。
  • 价格写在 config.pyPRICING_PRESETS),默认是 gpt-4o-mini 的公开单价(输入 $0.15 / 缓存 $0.075 / 输出 $0.60 每百万 token)。换模型:用 --model(如 --model gpt-4o)或 --price-* 直接覆盖单价即可;缓存命中要求稳定前缀 ≥ 1024 token,换更强模型不影响该机制。
  • 「工具返回注入」token 用 tiktoken 按当前模型的编码器离线估算(统计每轮输入里工具返回文本占多少 token),用于回答书中「一次工具返回可能占 2000-5000 token,且在后续每轮被反复计费」这一放大因素。
  • 凭据:首选 OPENAI_API_KEY;未设置时自动回退到 OPENROUTER_API_KEY(走 OpenRouter,gpt-* 映射为 openai/*)。gpt-5.x 直连需组织实名认证,故有 OPENROUTER_API_KEY 时优先走 OpenRouter。离线复算(--offline)无需任何 key。

源代码

agent.py

"""
一个多轮「客服退款 Agent」任务,用于成本分析(对应书 6.x 表6-4 的客服退款示例)。

为了让实验可复现、不依赖模型工具调用的随机性,这里用「受控工具环境」:
每一轮我们把上一步工具的返回结果喂给模型,由模型(真实 LLM 调用)决定下一步怎么做。
工具返回内容是预设好的(真实 API 里会是订单系统/物流系统的返回),
但每一次 LLM 调用、每一份 token 用量、每一分成本都是真实的。

本文件把「是否 KV-cache 友好」和「是否压缩上下文」两个开关正交拆开,
可组合出完整的 2×2 A/B(对应书中「对比启用/禁用 KV Cache、启用/禁用上下文压缩」):
  run_scenario(kv_cache=False, compress=False) —— A 朴素(前缀不稳定 + 不压缩)
  run_scenario(kv_cache=True,  compress=False) —— 仅 KV-cache(稳定长前缀,历史不压缩)
  run_scenario(kv_cache=False, compress=True)  —— 仅压缩(前缀不稳定,旧轮次摘要)
  run_scenario(kv_cache=True,  compress=True)  —— B 优化(稳定前缀 + 压缩,两个杠杆叠加)
兼容旧接口:run_naive == (False, False),run_optimized == (True, True)。
"""

import uuid
from functools import lru_cache

from config import MODEL, Pricing
from tracer import Tracer

# 最近保留几轮完整工具返回(更早的压成一句话摘要)。压缩关闭时视为无穷大。
KEEP_VERBOSE = 2

# 限制每轮输出长度:本实验聚焦「输入侧」的 KV-cache 与压缩两个杠杆,
# 把输出 token 控制在相近水平,可避免模型生成长度的随机波动干扰 A/B 成本对比。
MAX_OUTPUT_TOKENS = 160

# ---------------------------------------------------------------------------
# 一个「足够长且稳定」的系统提示 + 工具定义(> 1024 token),
# 这是 KV-cache 命中的关键:稳定的长前缀才会被 OpenAI 自动缓存。
# 内容是一个真实感的客服退款 Agent 的系统规范与工具手册。
# ---------------------------------------------------------------------------
STABLE_SYSTEM_PROMPT = """你是「云购商城」的高级客服 Agent,专门处理售后与退款事务。你必须严格遵循以下工作规范。

# 角色与目标
你的目标是在保障平台规则的前提下,高效、礼貌地帮助用户完成退款、退货、换货、物流查询等售后诉求。
你要主动澄清诉求、核对订单状态、判断是否符合退款政策,并在权限范围内执行操作。

# 可用工具手册(tool manual)
1. query_order(order_id): 查询订单详情。返回字段包括:order_id, status, item_name, sku, price,
   quantity, pay_time, pay_channel, buyer_note, seller_note, is_prepaid, warehouse, promotion_tags。
2. query_logistics(order_id): 查询物流轨迹。返回字段:carrier, tracking_no, current_status,
   last_scan_time, last_scan_location, estimated_delivery, full_trace(数组,含每个扫描节点)。
3. check_refund_policy(sku, reason): 查询该 SKU 在给定退款原因下的退款政策。返回字段:
   refundable, need_return, restocking_fee_rate, refund_window_days, special_notes, approval_required。
4. query_user_history(user_id): 查询用户历史行为,用于风控。返回:total_orders, refund_count_90d,
   dispute_count, risk_level, vip_tier, register_days。
5. issue_refund(order_id, amount, reason): 发起退款。返回:refund_id, status, expected_arrival,
   channel, operator。仅当政策允许且金额不超过订单实付金额时才可调用。
6. send_notification(user_id, channel, template, params): 给用户发通知(sms/app/email)。

# 决策规范
- 先核对订单是否存在、状态是否允许退款(已发货未签收、已签收 7 天内、未发货均有不同处理路径)。
- 未发货:可直接全额退款,无需退货。
- 已发货未签收:需拦截物流或等待退回,退款在退货签收后发起。
- 已签收 7 天内且商品无质量问题:适用 7 天无理由,可能收取一定比例的手续费(restocking_fee_rate)。
- 商品质量问题:全额退款且不收手续费,需用户提供凭证。
- 涉及大额退款(> 500 元)或高风险用户(risk_level=high)需要人工审批(approval_required=true)。
- 每一步都要给出简短的中文推理,说明你「基于什么信息、决定下一步调用哪个工具或给出什么结论」。

# 输出要求
- 保持专业、简洁、有同理心。
- 每轮只推进一步,不要臆造工具尚未返回的数据。
- 最终解决时,明确告知用户退款金额、到账时间与后续动作。

# 合规与风控
- 不得泄露其它用户信息;不得承诺超出政策的赔付;金额与政策以工具返回为准。
- 对疑似欺诈(短期高频退款、异常物流轨迹)要保持谨慎并触发人工审批。
请始终遵守以上全部规范。"""


# ---------------------------------------------------------------------------
# 预设的多轮剧本:用户诉求 + 每一步工具返回(真实 API 里来自后端系统)。
# 工具返回故意写得比较「啰嗦」(大 JSON),以体现工具结果注入上下文的 token 成本。
# ---------------------------------------------------------------------------
USER_REQUEST = (
    "你好,我上周买的蓝牙耳机(订单号 ORD20240517001)到货后一直连不上,"
    "试了各种办法都没用,我想退货退款,怎么处理?"
)

# 每一轮:(逻辑步骤名, 关联工具, 该工具的"啰嗦"返回文本)
# 工具返回都写得比较大(真实的订单/物流/知识库返回往往几百到上千 token),
# 以体现「工具结果注入上下文后在后续每一轮被反复计费」这一放大因素。
_LOGISTICS_TRACE = ",".join(
    '{"time":"2024-05-%02dT%02d:%02d","loc":"%s","desc":"%s","operator":"SF%04d","scan_type":"auto"}'
    % (17 + i // 6, 6 + i, (i * 7) % 60, loc, desc, 1000 + i)
    for i, (loc, desc) in enumerate([
        ("华东1仓", "包裹已揽收,称重0.42kg"), ("华东1仓分拣中心", "已分拣,发往上海转运"),
        ("上海转运中心", "到达转运中心"), ("上海转运中心", "已发出,运输中"),
        ("苏州中转场", "途经中转"), ("上海浦东集散点", "到达派送网点"),
        ("上海浦东集散点", "安排派送"), ("浦东xx营业点", "派送中,联系收件人"),
        ("浦东xx营业点", "首次派送未接通"), ("浦东xx营业点", "二次派送"),
        ("浦东xx营业点", "已签收,签收人:本人"),
    ])
)

TOOL_RESULTS = [
    ("turn-1", "query_order",
     '{"order_id":"ORD20240517001","status":"SIGNED","item_name":"Acme 主动降噪蓝牙耳机 Pro",'
     '"sku":"SKU-BT-9981","price":499.00,"quantity":1,"pay_time":"2024-05-17T10:22:31",'
     '"pay_channel":"wechat_pay","buyer_note":"希望尽快发货,送人用","seller_note":"已核对库存",'
     '"is_prepaid":true,"warehouse":"华东1仓","promotion_tags":["满300减30","会员日","新客礼"],'
     '"actual_paid":469.00,"coupon_used":"CPN-30","points_earned":469,"invoice_requested":false,'
     '"sign_time":"2024-05-19T14:03:11","after_sale_window_end":"2024-05-26T23:59:59",'
     '"sub_items":[{"sku":"SKU-BT-9981","name":"耳机主体","qty":1},{"sku":"SKU-BT-9981-CASE","name":"充电盒","qty":1},{"sku":"SKU-BT-9981-TIP","name":"耳塞套装","qty":1}],'
     '"address_hash":"a1b2c3d4","channel":"app","device":"iOS","order_source":"首页推荐位"}'),
    ("turn-2", "query_logistics",
     '{"carrier":"顺丰速运","tracking_no":"SF1234567890123","current_status":"已签收",'
     '"last_scan_time":"2024-05-19T14:03:11","last_scan_location":"上海市浦东新区xx营业点",'
     '"estimated_delivery":"2024-05-19","weight_kg":0.42,"volume":"20x15x8cm","insured":true,'
     '"full_trace":[' + _LOGISTICS_TRACE + ']}'),
    ("turn-3", "check_refund_policy",
     '{"sku":"SKU-BT-9981","reason":"quality_issue_cannot_connect","refundable":true,'
     '"need_return":true,"restocking_fee_rate":0.0,"refund_window_days":7,'
     '"special_notes":"质量问题类退款免手续费;需用户回寄并由质检确认是否为质量问题;'
     '若质检判定非质量问题(如人为损坏、私自拆修),将按原路退回商品且不予退款;'
     '回寄运费由平台承担,用户需在系统中申请电子面单;退款在质检通过后 1 个工作日内发起;'
     '3C 电子类目已激活/绑定账号的商品,需先解绑再回寄,否则质检不予通过。",'
     '"approval_required":false,"category":"3C-电子","quality_claim_supported":true,'
     '"return_label_provided":true,"qc_sla_days":2,"related_policy_ids":["P-3C-01","P-3C-07","P-QC-12"]}'),
    ("turn-4", "query_knowledge_base",
     '{"query":"蓝牙耳机无法连接 排查","hits":['
     '{"kb_id":"KB-1001","title":"蓝牙耳机无法连接的常见原因","content":"1.未进入配对模式;'
     '2.手机蓝牙缓存异常需忘记设备重连;3.固件版本过低;4.电量过低;5.多设备抢占连接。"},'
     '{"kb_id":"KB-1002","title":"Acme Pro 系列重置方法","content":"长按充电盒按键15秒至指示灯红白交替闪烁即完成重置,'
     '随后在手机端删除旧配对记录重新搜索。若重置后仍无法搜索到设备,多为硬件故障,建议走质量问题退换。"},'
     '{"kb_id":"KB-1003","title":"质量问题判定标准","content":"重置无效 + 换设备仍无法连接 + 无进液/外观损伤,'
     '通常判定为质量问题,支持免费退换。"}],"suggested_action":"引导用户重置;若无效则判定质量问题走退款流程"}'),
    ("turn-5", "query_user_history",
     '{"user_id":"U-88123","total_orders":37,"refund_count_90d":1,"dispute_count":0,'
     '"risk_level":"low","vip_tier":"gold","register_days":1180,"payment_disputes":0,'
     '"avg_order_value":312.5,"last_refund_reason":"尺码不合适","chargeback_count":0,'
     '"complaint_count":0,"account_status":"normal","fraud_flags":[],"lifetime_value":11562.5}'),
    ("turn-6", "issue_refund",
     '{"refund_id":"RF20240520777","status":"APPROVED","amount":469.00,'
     '"expected_arrival":"1-3 个工作日","channel":"原路退回-微信","operator":"agent-bot",'
     '"return_shipping":"平台承担","return_address":"华东1仓退货组","return_label":"SF-RET-998877",'
     '"qc_required":true,"qc_deadline":"2024-05-27","refund_flow":"pending_return->qc->refund"}'),
    ("turn-7", "send_notification",
     '{"user_id":"U-88123","channel":"app","template":"refund_approved",'
     '"delivered":true,"message_id":"MSG-556677","sent_time":"2024-05-20T15:20:03",'
     '"params":{"refund_id":"RF20240520777","amount":469.00,"return_label":"SF-RET-998877"},'
     '"read_receipt":false,"fallback_sms_scheduled":true}'),
    ("turn-8", "close_ticket",
     '{"ticket_id":"TK-20240520-3345","status":"resolved","resolution":"refund_after_return",'
     '"csat_survey_sent":true,"handle_time_s":184,"escalated":false,"agent":"agent-bot",'
     '"summary_logged":true,"tags":["退款","质量问题","3C","已闭环"]}'),
]

# 供「上下文压缩」策略使用的旧轮次一句话摘要(把啰嗦的工具返回压成要点)
TOOL_SUMMARIES = {
    "turn-1": "[摘要] 订单 ORD20240517001:Acme降噪耳机Pro,实付469元,已于5/19签收,售后窗口至5/26。",
    "turn-2": "[摘要] 物流:顺丰已签收(5/19 14:03,本人签收),11 个轨迹节点均正常无异常。",
    "turn-3": "[摘要] 退款政策:质量问题可退、免手续费,需回寄质检,回寄运费平台承担,无需人工审批。",
    "turn-4": "[摘要] 知识库:先引导重置耳机;重置无效即判定质量问题,支持免费退换。",
    "turn-5": "[摘要] 用户风控:37单/90天仅1次退款/low风险/gold会员,信誉良好,无欺诈标记。",
    "turn-6": "[摘要] 已发起退款 RF20240520777:469元原路退微信,需回寄质检,平台承担回寄运费。",
    "turn-7": "[摘要] 已通过 app 通知用户退款已批准,附回寄面单。",
}


def _next_user_msg(tool_name: str, tool_result: str) -> str:
    """把工具返回包装成喂给模型的下一条 user 消息。"""
    return (
        f"[工具 {tool_name} 返回结果]\n{tool_result}\n\n"
        f"请基于以上结果给出你的推理,并决定下一步动作。"
    )


@lru_cache(maxsize=1)
def _encoder():
    """按当前模型取 tiktoken 编码器(离线可用),用于估算「工具返回注入」的 token。"""
    import tiktoken
    try:
        return tiktoken.encoding_for_model(MODEL)
    except Exception:
        return tiktoken.get_encoding("cl100k_base")


def _ntok(text: str) -> int:
    return len(_encoder().encode(text))


# ---------------------------------------------------------------------------
# 四种 A/B 场景的登记表:名字 + 两个开关。
# ---------------------------------------------------------------------------
SCENARIOS = {
    "naive":    ("A 朴素(无缓存/无压缩)", False, False),
    "kv":       ("KV 仅缓存(稳定前缀/不压缩)", True, False),
    "compress": ("仅压缩(前缀不稳定/摘要)", False, True),
    "both":     ("B 优化(KV缓存+压缩)", True, True),
}


def build_messages(idx, step, tool, result, turns, kv_cache, compress):
    """构造第 idx 轮要发给模型的 messages,并返回本轮输入里「工具返回注入」的累计 token。

    kv_cache=True  → system 用逐字节稳定的长前缀(可被 OpenAI 自动缓存);
    kv_cache=False → 每轮在 system 最前面塞随机 session 头,破坏前缀一致性。
    compress=True  → 仅最近 KEEP_VERBOSE 轮保留完整工具返回,更早轮次压成一句话摘要。
    """
    if kv_cache:
        system = {"role": "system", "content": STABLE_SYSTEM_PROMPT}
    else:
        volatile_head = f"[会话追踪] session={uuid.uuid4()} 请求序号={uuid.uuid4()}\n\n"
        system = {"role": "system", "content": volatile_head + STABLE_SYSTEM_PROMPT}

    history = [{"role": "user", "content": USER_REQUEST}]
    tool_ctx_tokens = 0
    for j, (p_step, p_assistant, p_tool, p_result) in enumerate(turns):
        history.append({"role": "assistant", "content": p_assistant})
        if compress and idx - j > KEEP_VERBOSE:
            compact = TOOL_SUMMARIES.get(p_step, f"[摘要] {p_tool} 已完成。")
            history.append({"role": "user", "content": compact})
            tool_ctx_tokens += _ntok(compact)
        else:
            history.append({"role": "user", "content": _next_user_msg(p_tool, p_result)})
            tool_ctx_tokens += _ntok(p_result)

    messages = [system] + history + [
        {"role": "user", "content": _next_user_msg(tool, result)}
    ]
    tool_ctx_tokens += _ntok(result)   # 本轮新注入的工具返回
    return messages, tool_ctx_tokens


def run_scenario(client, kv_cache: bool, compress: bool, name: str = None,
                 pricing: Pricing = None) -> Tracer:
    """跑一遍 8 轮客服退款任务,两个开关正交组合出 2×2 中的一格。

    两组做的是同样的逻辑工作,只在上下文构造上不同,因此成本差异纯粹来自
    KV-cache 复用与上下文压缩这两个输入侧杠杆。
    """
    tracer = Tracer(client, name=name or f"kv={kv_cache},compress={compress}",
                    pricing=pricing)
    turns = []
    for idx, (step, tool, result) in enumerate(TOOL_RESULTS):
        messages, tool_ctx = build_messages(
            idx, step, tool, result, turns, kv_cache, compress)
        resp = tracer.chat(step=step, tool=tool, tool_ctx_tokens=tool_ctx,
                           model=MODEL, messages=messages, temperature=0,
                           max_tokens=MAX_OUTPUT_TOKENS)
        assistant_text = resp.choices[0].message.content or ""
        turns.append((step, assistant_text, tool, result))
    return tracer


def run_naive(client, pricing: Pricing = None) -> Tracer:
    """(a) 朴素做法:前缀不稳定 + 不压缩历史(KV-cache 命中不了、上下文疯长)。"""
    return run_scenario(client, kv_cache=False, compress=False,
                        name=SCENARIOS["naive"][0], pricing=pricing)


def run_optimized(client, pricing: Pricing = None) -> Tracer:
    """(b) KV-cache 友好 + 上下文压缩:稳定长前缀命中缓存 + 旧轮次摘要。"""
    return run_scenario(client, kv_cache=True, compress=True,
                        name=SCENARIOS["both"][0], pricing=pricing)

config.py

"""
全局配置:模型与价格。

价格换算成本时使用「每百万 token 单价(美元)」。
默认值取自 OpenAI gpt-4o-mini 的公开定价(2024-2025):
    - 输入        : $0.15  / 1M tokens
    - 缓存命中输入 : $0.075 / 1M tokens   (命中 prompt cache 的输入按 5 折计费)
    - 输出        : $0.60  / 1M tokens

注意:
1. 默认模型为 gpt-5.6-luna(当前廉价旗舰)。首选凭据是 OPENAI_API_KEY;若未设置,
   自动回退到 OPENROUTER_API_KEY 并把模型名映射成 OpenRouter id(gpt-* -> openai/*)。
   由于 gpt-5.x 直连 OpenAI 需要组织实名认证,只要 OPENROUTER_API_KEY 存在就优先走
   OpenRouter(见 make_client_and_model)。仍可用 COST_DEMO_MODEL / --model 切换任意模型。
2. OpenAI 的 prompt caching 是「自动」的:当请求前缀 >= 1024 token 且与近期请求
   命中相同前缀时,usage.prompt_tokens_details.cached_tokens 会大于 0,
   这部分 token 按缓存价(更便宜)计费。本项目正是用它来真实体现 KV-cache 的节省。
   (OpenRouter 转发 OpenAI 时同样在 prompt_tokens_details.cached_tokens 回传缓存命中。)
"""

import os
from dataclasses import dataclass

# 使用的模型(默认当前廉价旗舰 gpt-5.6-luna;可用 COST_DEMO_MODEL / --model 覆盖)
MODEL = os.environ.get("COST_DEMO_MODEL", "gpt-5.6-luna")

# OpenRouter 回退:无 OPENAI_API_KEY 时用 OPENROUTER_API_KEY 走 OpenAI 兼容端点。
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"


def _to_openrouter_model(model: str) -> str:
    """把模型名映射成 OpenRouter id:含 '/' 视为原生 id;gpt-* -> openai/*;
    claude-* -> anthropic/claude-opus-4.8;其余回退到 openai/gpt-5.6-luna。"""
    if "/" in model:
        return model
    if model.startswith("gpt-"):
        return "openai/" + model
    if model.startswith("claude-"):
        return "anthropic/claude-opus-4.8"
    return "openai/gpt-5.6-luna"


def make_client_and_model(model: str):
    """构造 OpenAI 兼容 client 并返回 (client, 实际调用的模型名)。

    回退策略(universal OpenRouter fallback):
      - gpt-5.x 且存在 OPENROUTER_API_KEY -> 优先走 OpenRouter(直连需组织实名认证);
      - 否则有 OPENAI_API_KEY -> 直连 OpenAI,模型名不变;
      - 否则有 OPENROUTER_API_KEY -> 走 OpenRouter,模型名按 _to_openrouter_model 映射;
      - 两者皆无 -> 抛出清晰错误。
    """
    from openai import OpenAI

    primary = os.environ.get("OPENAI_API_KEY", "").strip()
    orkey = os.environ.get("OPENROUTER_API_KEY", "").strip()
    prefer_openrouter = bool(orkey) and model.startswith("gpt-5")

    if not prefer_openrouter and primary:
        return OpenAI(timeout=60.0, max_retries=2), model
    if orkey:
        return (
            OpenAI(base_url=OPENROUTER_BASE_URL, api_key=orkey,
                   timeout=60.0, max_retries=2),
            _to_openrouter_model(model),
        )
    if primary:
        return OpenAI(timeout=60.0, max_retries=2), model
    raise RuntimeError(
        "缺少可用凭据:请设置 OPENAI_API_KEY(直连 OpenAI),或设置 "
        "OPENROUTER_API_KEY(自动回退到 OpenRouter);或改用 --offline 离线复算(无需 key)。"
    )

# 每百万 token 的美元单价(默认 gpt-4o-mini)
PRICE_INPUT_PER_M = 0.15      # 普通输入
PRICE_CACHED_PER_M = 0.075    # 命中缓存的输入(gpt-4o-mini 缓存读取为输入价的 50%)
PRICE_OUTPUT_PER_M = 0.60     # 输出


@dataclass(frozen=True)
class Pricing:
    """一组每百万 token 的美元单价。"""
    input_per_m: float
    cached_per_m: float
    output_per_m: float

    def cost_usd(self, prompt_tokens: int, cached_tokens: int,
                 completion_tokens: int) -> float:
        """按 token 用量换算成本(美元)。

        prompt_tokens   : usage.prompt_tokens,包含了缓存命中的部分
        cached_tokens   : usage.prompt_tokens_details.cached_tokens,命中缓存的输入 token
        completion_tokens: usage.completion_tokens

        未命中缓存的输入 = prompt_tokens - cached_tokens,按普通输入价计费;
        命中缓存的输入按缓存价计费。
        """
        uncached_input = max(prompt_tokens - cached_tokens, 0)
        return (
            uncached_input / 1_000_000 * self.input_per_m
            + cached_tokens / 1_000_000 * self.cached_per_m
            + completion_tokens / 1_000_000 * self.output_per_m
        )


# 常见 OpenAI 模型的公开单价预设(每百万 token,美元),方便 CLI 用 --model 一键切换。
# 换更强的模型不影响 KV-cache 机制(仍要求稳定前缀 >= 1024 token)。
PRICING_PRESETS = {
    "gpt-4o-mini": Pricing(0.15, 0.075, 0.60),
    "gpt-4o":      Pricing(2.50, 1.25, 10.00),
    "gpt-4.1-mini": Pricing(0.40, 0.10, 1.60),
    "gpt-4.1":     Pricing(2.00, 0.50, 8.00),
}


def default_pricing() -> Pricing:
    """返回默认模型(config 中 MODEL)的单价;未知模型回退到模块级 PRICE_* 默认值。"""
    return PRICING_PRESETS.get(
        MODEL, Pricing(PRICE_INPUT_PER_M, PRICE_CACHED_PER_M, PRICE_OUTPUT_PER_M)
    )


def cost_usd(prompt_tokens: int, cached_tokens: int, completion_tokens: int,
             pricing: "Pricing | None" = None) -> float:
    """按 token 用量换算成本(美元)。默认用模块级单价,可传入自定义 Pricing。"""
    p = pricing or Pricing(PRICE_INPUT_PER_M, PRICE_CACHED_PER_M, PRICE_OUTPUT_PER_M)
    return p.cost_usd(prompt_tokens, cached_tokens, completion_tokens)

demo.py

"""
实验 6-7:Agent 任务的端到端成本分析(可运行 demo + CLI)。

两种运行方式:
  1) 在线(--live,默认):真实调用模型(默认 gpt-5.6-luna),token 与 cached_tokens
     取自 API 返回的 usage,成本按单价换算。需要 OPENAI_API_KEY 或 OPENROUTER_API_KEY
     (无 OpenAI key 时自动回退到 OpenRouter;gpt-5.x 只要有 OpenRouter key 就优先走它)。
  2) 离线(--offline):不打模型,读入一份此前真实运行录下的 trace(canned token
     counts),用可配置的单价重新计算成本、成本构成与 A/B 对比表。无需 API key。

无论哪种方式,都会产出两份交付:
  (a) 单次任务的「按步骤 + 按成本构成」拆解(哪一步最贵、输入/缓存/输出各占多少)。
  (b) A/B 对比表:朴素 vs 仅 KV-cache vs 仅压缩 vs 两者叠加(完整 2×2),
     量化 总 token / 缓存 token / 缓存率 / 成本 / 相对基线的节省。

示例:
  python demo.py                          # 在线,默认跑 A(朴素)+B(优化) 两组
  python demo.py --scenario all           # 在线,跑完整 2×2 四组
  python demo.py --live --save-trace out.json   # 在线跑并把真实用量落盘
  python demo.py --offline                # 离线,用内置 sample_trace.json 重算
  python demo.py --offline --model gpt-4o # 离线,换 gpt-4o 单价重算同一份用量
  python demo.py --offline --price-input 0.20 --price-cached 0.10 --price-output 0.80
"""

import argparse
import json
import os
import sys

import config
from config import PRICING_PRESETS, Pricing


DEFAULT_TRACE = os.path.join(os.path.dirname(__file__), "sample_trace.json")
SCENARIO_KEYS = ["naive", "kv", "compress", "both"]


def _pct(saved: float, base: float) -> str:
    if base == 0:
        return "0.0%"
    return f"{saved / base * 100:.1f}%"


def build_pricing(args) -> Pricing:
    """根据 --model 预设 + --price-* 覆盖,构造本次计费用的单价。"""
    base = PRICING_PRESETS.get(args.model)
    if base is None:
        base = config.default_pricing()
    return Pricing(
        input_per_m=args.price_input if args.price_input is not None else base.input_per_m,
        cached_per_m=args.price_cached if args.price_cached is not None else base.cached_per_m,
        output_per_m=args.price_output if args.price_output is not None else base.output_per_m,
    )


def resolve_scenarios(arg: str):
    """把 --scenario 解析成有序去重的场景 key 列表。"""
    if arg == "all":
        return list(SCENARIO_KEYS)
    if arg == "ab":
        return ["naive", "both"]
    keys, seen = [], set()
    for k in arg.split(","):
        k = k.strip()
        if k and k not in seen:
            keys.append(k)
            seen.add(k)
    return keys


# ---------------------------------------------------------------------------
# 采集:在线跑真实模型,或离线从 trace 文件读回
# ---------------------------------------------------------------------------
def collect_live(keys, pricing, warmup: bool):
    import agent

    if not (os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY")):
        print("未检测到 OPENAI_API_KEY 或 OPENROUTER_API_KEY,请先 export 其一 "
              "(无 OpenAI key 时会自动回退到 OpenRouter),或改用 --offline(离线复算,无需 key)。",
              file=sys.stderr)
        sys.exit(1)

    # 构造 client 并解析实际模型名(可能被回退映射成 OpenRouter id)。
    client, resolved = config.make_client_and_model(config.MODEL)
    if resolved != config.MODEL:
        print(f">>> 已回退到 OpenRouter:模型 {config.MODEL} -> {resolved}")
        config.MODEL = resolved
        agent.MODEL = resolved
        try:
            agent._encoder.cache_clear()
        except Exception:
            pass
    tracers = []
    for k in keys:
        name, kv, compress = agent.SCENARIOS[k]
        # KV-cache 组先跑一次「预热」,把稳定前缀写入 OpenAI 的 prompt cache,
        # 让正式计量时更稳定地命中 cached_tokens(真实系统里前缀早已是热的)。
        if kv and warmup:
            print(f">>> 预热 [{name}] 的稳定前缀(写入 prompt cache)...")
            agent.run_scenario(client, kv, compress, name=name, pricing=pricing)
        print(f">>> 正在运行 [{name}] {'(在线计量)' if kv else ''}...")
        tr = agent.run_scenario(client, kv, compress, name=name, pricing=pricing)
        tracers.append((k, tr))
    return tracers


def collect_offline(keys, pricing, trace_path):
    from tracer import Tracer

    if not os.path.exists(trace_path):
        print(f"找不到 trace 文件:{trace_path}", file=sys.stderr)
        sys.exit(1)
    with open(trace_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    by_key = {s.get("key", s.get("name")): s for s in data.get("scenarios", [])}
    print(f"离线模式:读入 {trace_path}")
    print(f"  该 trace 采集自模型 = {data.get('model', '?')},"
          f"共 {len(by_key)} 个场景的真实录制用量(token 数为实测,成本按当前单价重算)。")

    tracers = []
    for k in keys:
        sc = by_key.get(k)
        if sc is None:
            print(f"  [跳过] trace 中没有场景 '{k}'(可用在线模式 --save-trace 补录)",
                  file=sys.stderr)
            continue
        spans = sc.get("spans")
        if not spans:
            print(f"  [跳过] trace 中场景 '{k}' 缺少 spans 数据"
                  f"(可用在线模式 --save-trace 补录)", file=sys.stderr)
            continue
        tr = Tracer.from_records(spans, name=sc.get("name", k), pricing=pricing)
        tracers.append((k, tr))
    if not tracers:
        print("trace 里没有任何被选中的场景,退出。", file=sys.stderr)
        sys.exit(1)
    return tracers


# ---------------------------------------------------------------------------
# 交付 (b):A/B 对比表
# ---------------------------------------------------------------------------
def print_ab_table(tracers):
    print("\n\n===== A/B 成本对比(同一个 8 轮客服退款任务)=====")
    header = (f"{'方案':<26} {'总输入tok':>10} {'缓存tok':>10} {'缓存率':>8} "
              f"{'输出tok':>8} {'总成本($)':>12} {'vs基线':>10}")
    print(header)
    print("-" * len(header))

    base_cost = tracers[0][1].total_cost()
    for _, tr in tracers:
        pin = tr.total_prompt_tokens()
        cac = tr.total_cached_tokens()
        rate = f"{(cac / pin * 100):.1f}%" if pin else "0.0%"
        cost = tr.total_cost()
        vs = "基线" if abs(cost - base_cost) < 1e-12 else f"-{_pct(base_cost - cost, base_cost)}"
        print(f"{tr.name:<26} {pin:>10} {cac:>10} {rate:>8} "
              f"{tr.total_completion_tokens():>8} {cost:>12.6f} {vs:>10}")
    print("-" * len(header))

    # 用第一个(基线)和最后一个(通常是 both 优化)做重点量化
    base_k, base = tracers[0]
    best_k, best = tracers[-1]
    if base_k != best_k:
        tok_a = base.total_prompt_tokens() + base.total_completion_tokens()
        tok_b = best.total_prompt_tokens() + best.total_completion_tokens()
        cost_a, cost_b = base.total_cost(), best.total_cost()
        print(f"\n重点对比:{base.name}{best.name}")
        print(f"  总 token:   A={tok_a}  →  B={tok_b}   "
              f"减少 {tok_a - tok_b} ({_pct(tok_a - tok_b, tok_a)})")
        print(f"  缓存 token: A={base.total_cached_tokens()}  →  "
              f"B={best.total_cached_tokens()}   (B 靠稳定前缀命中缓存)")
        print(f"  总成本:     A=${cost_a:.6f}  →  B=${cost_b:.6f}   "
              f"降低 ${cost_a - cost_b:.6f} ({_pct(cost_a - cost_b, cost_a)})")
        if cost_b > 0:
            print(f"  成本倍率:   A 是 B 的 {cost_a / cost_b:.2f} 倍")

    print("\n结论: 稳定长前缀让重复的系统提示/工具定义/历史轮次按缓存价计费,")
    print("      叠加上下文压缩控制上下文增长,二者共同显著降低了端到端成本。")


def dump_output(path, tracers, pricing, model):
    out = {
        "model": model,
        "pricing": {"input": pricing.input_per_m, "cached": pricing.cached_per_m,
                    "output": pricing.output_per_m},
        "scenarios": [],
    }
    import agent
    for k, tr in tracers:
        name = agent.SCENARIOS[k][0] if k in agent.SCENARIOS else tr.name
        out["scenarios"].append({
            "key": k, "name": name,
            "total_cost": tr.total_cost(),
            "component_costs": tr.component_costs(),
            "cost_distribution": tr.cost_distribution(),
            "spans": tr.to_records(),
        })
    with open(path, "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2)
    print(f"\n已写出结果到 {path}")


def build_parser():
    p = argparse.ArgumentParser(
        prog="demo.py",
        description="实验 6-7:Agent 任务端到端成本分析——对客服退款 Agent 做全链路成本拆解,"
                    "并对比 KV-cache / 上下文压缩两个杠杆的成本差异(完整 2×2 A/B)。",
        epilog="示例:\n"
               "  python demo.py                       # 在线,默认跑 A(朴素)+B(优化)\n"
               "  python demo.py --scenario all        # 在线,跑完整 2×2 四组\n"
               "  python demo.py --offline             # 离线,用内置 canned trace 重算(无需 key)\n"
               "  python demo.py --offline --model gpt-4o   # 换单价离线重算\n"
               "  python demo.py --live --save-trace out.json  # 在线跑并落盘真实用量\n",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    mode = p.add_mutually_exclusive_group()
    mode.add_argument("--live", action="store_true",
                      help="在线模式(默认):真实调用 OpenAI,需要 OPENAI_API_KEY。")
    mode.add_argument("--offline", action="store_true",
                      help="离线模式:不打模型,从 trace 文件读真实录制的 token 用量并按单价重算成本。")
    p.add_argument("--trace", metavar="FILE", default=DEFAULT_TRACE,
                   help=f"离线模式读取的 trace(canned token counts)文件,默认 {os.path.basename(DEFAULT_TRACE)}。")
    p.add_argument("--save-trace", metavar="FILE", default=None,
                   help="在线模式下把本次真实 token 用量落盘为 trace 文件(供之后 --offline 复算)。")
    p.add_argument("--scenario", metavar="NAME", default="ab",
                   help="选择要跑的 A/B 场景:ab(默认,=naive+both) / all(2×2 四组) / "
                        "或逗号分隔的子集 naive,kv,compress,both。")
    p.add_argument("--model", metavar="NAME", default=config.MODEL,
                   help=f"模型名(决定默认单价预设,可选 {', '.join(PRICING_PRESETS)}),"
                        f"默认 {config.MODEL}。")
    p.add_argument("--price-input", type=float, default=None,
                   help="覆盖输入单价(每百万 token 美元)。")
    p.add_argument("--price-cached", type=float, default=None,
                   help="覆盖缓存命中输入单价(每百万 token 美元)。")
    p.add_argument("--price-output", type=float, default=None,
                   help="覆盖输出单价(每百万 token 美元)。")
    p.add_argument("--no-warmup", action="store_true",
                   help="在线模式下关闭 KV-cache 组的前缀预热(默认预热以稳定命中缓存)。")
    p.add_argument("--output", metavar="FILE", default=None,
                   help="把成本拆解结果(含成本构成/分布/逐步用量)写成 JSON 文件。")
    return p


def main():
    args = build_parser().parse_args()

    # 让 agent / tracer 使用选定模型
    config.MODEL = args.model
    try:
        import agent
        agent.MODEL = args.model
        agent._encoder.cache_clear()
    except Exception:
        pass

    pricing = build_pricing(args)
    keys = resolve_scenarios(args.scenario)
    bad = [k for k in keys if k not in SCENARIO_KEYS]
    if bad:
        print(f"未知场景 {bad},可选:{SCENARIO_KEYS} / all / ab", file=sys.stderr)
        sys.exit(2)

    print(f"模型: {args.model}")
    print(f"单价(每百万token): 输入 ${pricing.input_per_m} / 缓存输入 ${pricing.cached_per_m} "
          f"/ 输出 ${pricing.output_per_m}")

    if args.offline:
        tracers = collect_offline(keys, pricing, args.trace)
    else:
        print("说明: OpenAI prompt caching 自动生效(前缀>=1024token 且近期命中相同前缀),")
        print("      命中的输入 token 出现在 usage.prompt_tokens_details.cached_tokens。")
        tracers = collect_live(keys, pricing, warmup=not args.no_warmup)
        if args.save_trace:
            dump_output(args.save_trace, tracers, pricing, args.model)

    # 交付 (a):逐场景成本拆解
    for _, tr in tracers:
        tr.print_breakdown(title=f"{tr.name}(单次任务全链路拆解)")

    # 交付 (b):A/B 对比表
    if len(tracers) >= 2:
        print_ab_table(tracers)

    if args.output:
        dump_output(args.output, tracers, pricing, args.model)


if __name__ == "__main__":
    main()

test_trace_offline.py

"""
Regression tests for offline trace parsing (实验 6-7 成本分析).

Covers two crash classes found in --offline mode:
  - Tracer.from_records: trace JSON with explicit null token fields -> int(None) TypeError
  - demo.collect_offline: scenario dict missing the optional "spans" key -> KeyError
"""
import json

import pytest

import config
import demo
from tracer import Tracer


def _span(**overrides):
    span = {
        "step": "turn-1",
        "tool": "query_order",
        "kind": "llm",
        "prompt_tokens": 100,
        "cached_tokens": 10,
        "completion_tokens": 12,
        "tool_ctx_tokens": 50,
        "latency_s": 1.2,
    }
    span.update(overrides)
    return span


def test_from_records_tolerates_null_fields():
    """Explicit JSON nulls in numeric fields are coerced, not int(None) TypeError."""
    records = [_span(prompt_tokens=None, cached_tokens=None,
                     completion_tokens=None, tool_ctx_tokens=None, latency_s=None)]
    tr = Tracer.from_records(records, pricing=config.default_pricing())
    s = tr.spans[0]
    assert s.prompt_tokens == 0
    assert s.cached_tokens == 0
    assert s.completion_tokens == 0
    assert s.tool_ctx_tokens == -1   # null tool_ctx 视为「未知」
    assert s.latency_s == 0.0


def test_from_records_tolerates_missing_fields():
    """Minimal span dicts (only step/tool) still parse."""
    tr = Tracer.from_records([{"step": "turn-1", "tool": "query_order"}],
                             pricing=config.default_pricing())
    assert tr.spans[0].prompt_tokens == 0
    assert tr.spans[0].tool_ctx_tokens == -1


def test_from_records_keeps_real_values():
    """Normal values pass through unchanged (no coercion side effects)."""
    tr = Tracer.from_records([_span()], pricing=config.default_pricing())
    s = tr.spans[0]
    assert (s.prompt_tokens, s.cached_tokens, s.completion_tokens) == (100, 10, 12)
    assert s.tool_ctx_tokens == 50
    assert s.latency_s == 1.2


def _write_trace(tmp_path, scenarios):
    path = tmp_path / "trace.json"
    path.write_text(json.dumps({"model": "gpt-5.6-luna", "scenarios": scenarios}),
                    encoding="utf-8")
    return str(path)


def test_collect_offline_skips_scenario_without_spans(tmp_path, capsys):
    """A scenario missing 'spans' is skipped with a warning, not a KeyError crash."""
    trace = _write_trace(tmp_path, [
        {"key": "naive", "name": "A naive"},                      # no spans -> skip
        {"key": "both", "name": "B both", "spans": [_span()]},    # valid
    ])
    tracers = demo.collect_offline(["naive", "both"], config.default_pricing(), trace)
    assert [k for k, _ in tracers] == ["both"]
    assert "缺少 spans" in capsys.readouterr().err


def test_collect_offline_exits_when_no_usable_scenario(tmp_path):
    """When every selected scenario lacks spans, exit cleanly like the empty-trace path."""
    trace = _write_trace(tmp_path, [{"key": "naive", "name": "A naive"}])
    with pytest.raises(SystemExit):
        demo.collect_offline(["naive"], config.default_pricing(), trace)


if __name__ == "__main__":
    pytest.main([__file__, "-v"])

tracer.py

"""
自建的轻量级 tracing / 可观测系统。

设计沿用分布式追踪的 span 树模型(见书 6.x「Agent 的可观测性」):
    - 一次 agent 任务 = 一条 Trace
    - 每次 LLM 调用 / 工具调用 = 一个 Span
    - Span 记录:所属步骤、类型、token 用量(prompt/completion/cached)、时延、成本

用法:
    tracer = Tracer(client)
    resp = tracer.chat(step="turn-1", tool="query_order",
                       model=..., messages=..., temperature=0)
    tracer.print_breakdown()   # 打印按步骤/工具聚合的成本拆解

离线复用(不打模型、只算成本):
    tracer = Tracer.from_records(records, pricing=..., name=...)
    # records 里是此前真实运行录下的每一步 token 用量(canned token counts)
"""

import time
from dataclasses import dataclass, asdict, field
from typing import List, Optional

from config import Pricing, default_pricing


def _percentile(values: List[float], q: float) -> float:
    """最近秩(nearest-rank)百分位,避免引入 numpy 依赖。q 取 0~100。"""
    if not values:
        return 0.0
    xs = sorted(values)
    if len(xs) == 1:
        return xs[0]
    rank = max(1, min(len(xs), int(round(q / 100.0 * len(xs) + 0.5))))
    return xs[rank - 1]


@dataclass
class Span:
    """一次被追踪的调用(这里主要是 LLM 调用)。"""
    step: str                 # 逻辑步骤名,如 "turn-2"
    tool: str                 # 该步骤关联的工具/动作名,用于归因“哪一步最贵”
    kind: str = "llm"         # span 类型:llm / tool
    prompt_tokens: int = 0
    cached_tokens: int = 0
    completion_tokens: int = 0
    # 该轮输入里「工具返回结果」占用的累计 token(同一份工具返回会在后续每轮被反复计费)。
    # 由上层用 tokenizer 估算并填入;离线复用时从 records 读回。-1 表示未知。
    tool_ctx_tokens: int = -1
    latency_s: float = 0.0
    cost_usd: float = 0.0

    @property
    def total_tokens(self) -> int:
        return self.prompt_tokens + self.completion_tokens

    @property
    def uncached_prompt_tokens(self) -> int:
        return max(self.prompt_tokens - self.cached_tokens, 0)


class Tracer:
    """包裹 OpenAI client,自动记录每次 LLM 调用的 usage / 时延 / 成本。"""

    def __init__(self, client=None, name: str = "trace",
                 pricing: Optional[Pricing] = None):
        self.client = client
        self.name = name
        self.pricing = pricing or default_pricing()
        self.spans: List[Span] = []

    # ---------- 采集 ----------
    def chat(self, step: str, tool: str, tool_ctx_tokens: int = -1, **kwargs):
        """发起一次被追踪的 chat.completions 调用。

        kwargs 原样透传给 openai client(model / messages / temperature 等)。
        tool_ctx_tokens:本轮输入里工具返回结果占用的累计 token(可选,用于成本归因)。
        返回原始的 OpenAI response 对象,方便上层取 content。
        """
        t0 = time.time()
        resp = self.client.chat.completions.create(**kwargs)
        latency = time.time() - t0

        usage = resp.usage
        # cached_tokens 藏在 prompt_tokens_details 里,注意做防御式读取
        cached = 0
        details = getattr(usage, "prompt_tokens_details", None)
        if details is not None:
            cached = getattr(details, "cached_tokens", 0) or 0

        span = Span(
            step=step,
            tool=tool,
            kind="llm",
            prompt_tokens=usage.prompt_tokens,
            cached_tokens=cached,
            completion_tokens=usage.completion_tokens,
            tool_ctx_tokens=tool_ctx_tokens,
            latency_s=latency,
            cost_usd=self.pricing.cost_usd(
                usage.prompt_tokens, cached, usage.completion_tokens),
        )
        self.spans.append(span)
        return resp

    # ---------- 离线复用(canned token counts → 重新计成本)----------
    @classmethod
    def from_records(cls, records: List[dict], name: str = "trace",
                     pricing: Optional[Pricing] = None) -> "Tracer":
        """用此前录下的 token 用量重建一条 trace,并按给定单价重算成本(不打模型)。"""
        tr = cls(client=None, name=name, pricing=pricing)
        for r in records:
            span = Span(
                step=r.get("step", ""),
                tool=r.get("tool", ""),
                kind=r.get("kind", "llm"),
                # 防御:trace JSON 里字段缺失或显式为 null 时按 0(tool_ctx 按未知 -1)处理
                prompt_tokens=int(r.get("prompt_tokens") or 0),
                cached_tokens=int(r.get("cached_tokens") or 0),
                completion_tokens=int(r.get("completion_tokens") or 0),
                tool_ctx_tokens=int(r.get("tool_ctx_tokens") or -1),
                latency_s=float(r.get("latency_s") or 0.0),
            )
            span.cost_usd = tr.pricing.cost_usd(
                span.prompt_tokens, span.cached_tokens, span.completion_tokens)
            tr.spans.append(span)
        return tr

    def to_records(self) -> List[dict]:
        """导出每一步的原始 token 用量(用于落盘成 canned trace,供离线复用)。"""
        out = []
        for s in self.spans:
            d = asdict(s)
            d.pop("cost_usd", None)   # 成本由单价重算,不落盘固定值
            out.append(d)
        return out

    # ---------- 聚合 ----------
    def total_cost(self) -> float:
        return sum(s.cost_usd for s in self.spans)

    def total_prompt_tokens(self) -> int:
        return sum(s.prompt_tokens for s in self.spans)

    def total_cached_tokens(self) -> int:
        return sum(s.cached_tokens for s in self.spans)

    def total_completion_tokens(self) -> int:
        return sum(s.completion_tokens for s in self.spans)

    def total_uncached_prompt_tokens(self) -> int:
        return sum(s.uncached_prompt_tokens for s in self.spans)

    def total_tool_ctx_tokens(self) -> int:
        return sum(s.tool_ctx_tokens for s in self.spans if s.tool_ctx_tokens >= 0)

    def total_latency(self) -> float:
        return sum(s.latency_s for s in self.spans)

    def cache_rate(self) -> float:
        pin = self.total_prompt_tokens()
        return self.total_cached_tokens() / pin if pin else 0.0

    def component_costs(self) -> dict:
        """把总成本拆成三个成本构成要素(对应书「成本的构成要素」):
            - 未缓存输入 / 缓存输入 / 输出
        以及输入侧里「工具返回注入」token 占比(若已知)。"""
        p = self.pricing
        uncached_in = self.total_uncached_prompt_tokens()
        cached_in = self.total_cached_tokens()
        out = self.total_completion_tokens()
        return {
            "uncached_input_cost": uncached_in / 1_000_000 * p.input_per_m,
            "cached_input_cost": cached_in / 1_000_000 * p.cached_per_m,
            "output_cost": out / 1_000_000 * p.output_per_m,
            "uncached_input_tokens": uncached_in,
            "cached_input_tokens": cached_in,
            "output_tokens": out,
            "tool_ctx_tokens": self.total_tool_ctx_tokens(),
        }

    def cost_distribution(self) -> dict:
        """按步骤的单步成本分布(p50/p95/p99)。对应书「成本分布 p50/p95/p99」。"""
        costs = [s.cost_usd for s in self.spans]
        n = len(costs)
        return {
            "n": n,
            "mean": (sum(costs) / n) if n else 0.0,
            "p50": _percentile(costs, 50),
            "p95": _percentile(costs, 95),
            "p99": _percentile(costs, 99),
            "max": max(costs) if costs else 0.0,
        }

    # ---------- 打印 ----------
    def print_breakdown(self, title: Optional[str] = None):
        """打印一次 agent 任务的按步骤成本拆解表,并指出最贵的一步、成本构成与分布。"""
        print()
        print(f"===== 成本拆解: {title or self.name} =====")
        header = (
            f"{'步骤':<8} {'工具/动作':<20} {'输入tok':>8} {'缓存tok':>8} "
            f"{'工具tok':>8} {'输出tok':>8} {'时延(s)':>8} {'成本($)':>12}"
        )
        print(header)
        print("-" * len(header))
        for s in self.spans:
            tctx = s.tool_ctx_tokens if s.tool_ctx_tokens >= 0 else "-"
            print(
                f"{s.step:<8} {s.tool:<20} {s.prompt_tokens:>8} {s.cached_tokens:>8} "
                f"{str(tctx):>8} {s.completion_tokens:>8} {s.latency_s:>8.2f} "
                f"{s.cost_usd:>12.6f}"
            )
        print("-" * len(header))
        tctx_total = self.total_tool_ctx_tokens() if any(
            s.tool_ctx_tokens >= 0 for s in self.spans) else "-"
        print(
            f"{'合计':<8} {'':<20} {self.total_prompt_tokens():>8} "
            f"{self.total_cached_tokens():>8} {str(tctx_total):>8} "
            f"{self.total_completion_tokens():>8} "
            f"{self.total_latency():>8.2f} {self.total_cost():>12.6f}"
        )

        # 归因:哪一步最贵
        if self.spans:
            worst = max(self.spans, key=lambda s: s.cost_usd)
            total = self.total_cost()
            share = worst.cost_usd / total * 100 if total else 0
            print(
                f"\n最贵的一步 → {worst.step} / {worst.tool}: "
                f"${worst.cost_usd:.6f}(占总成本 {share:.1f}%)"
            )

        # 成本构成拆解(未缓存输入 / 缓存输入 / 输出)
        comp = self.component_costs()
        total = self.total_cost() or 1e-12
        print("成本构成:")
        print(f"  未缓存输入  {comp['uncached_input_tokens']:>8} tok  "
              f"${comp['uncached_input_cost']:.6f}  ({comp['uncached_input_cost']/total*100:.1f}%)")
        print(f"  缓存输入    {comp['cached_input_tokens']:>8} tok  "
              f"${comp['cached_input_cost']:.6f}  ({comp['cached_input_cost']/total*100:.1f}%)")
        print(f"  输出        {comp['output_tokens']:>8} tok  "
              f"${comp['output_cost']:.6f}  ({comp['output_cost']/total*100:.1f}%)")
        if comp["tool_ctx_tokens"] > 0:
            print(f"  其中「工具返回注入」累计输入 {comp['tool_ctx_tokens']} tok "
                  f"(同一份工具返回在后续每轮被反复计费)")

        # 单步成本分布
        dist = self.cost_distribution()
        print(f"单步成本分布(n={dist['n']}): 均值 ${dist['mean']:.6f}  "
              f"p50 ${dist['p50']:.6f}  p95 ${dist['p95']:.6f}  p99 ${dist['p99']:.6f}")

sample_trace.json

{
  "model": "gpt-4o-mini",
  "pricing": {
    "input": 0.15,
    "cached": 0.075,
    "output": 0.6
  },
  "scenarios": [
    {
      "key": "naive",
      "name": "A 朴素(无缓存/无压缩)",
      "total_cost": 0.0037758,
      "component_costs": {
        "uncached_input_cost": 0.0031049999999999997,
        "cached_input_cost": 0.0,
        "output_cost": 0.0006708,
        "uncached_input_tokens": 20700,
        "cached_input_tokens": 0,
        "output_tokens": 1118,
        "tool_ctx_tokens": 9544
      },
      "cost_distribution": {
        "n": 8,
        "mean": 0.000471975,
        "p50": 0.00048059999999999997,
        "p95": 0.0006462,
        "p99": 0.0006462,
        "max": 0.0006462
      },
      "spans": [
        {
          "step": "turn-1",
          "tool": "query_order",
          "kind": "llm",
          "prompt_tokens": 1113,
          "cached_tokens": 0,
          "completion_tokens": 104,
          "tool_ctx_tokens": 276,
          "latency_s": 3.1535720825195312
        },
        {
          "step": "turn-2",
          "tool": "query_logistics",
          "kind": "llm",
          "prompt_tokens": 1807,
          "cached_tokens": 0,
          "completion_tokens": 99,
          "tool_ctx_tokens": 829,
          "latency_s": 2.0884652137756348
        },
        {
          "step": "turn-3",
          "tool": "check_refund_policy",
          "kind": "llm",
          "prompt_tokens": 2154,
          "cached_tokens": 0,
          "completion_tokens": 139,
          "tool_ctx_tokens": 1046,
          "latency_s": 2.685513973236084
        },
        {
          "step": "turn-4",
          "tool": "query_knowledge_base",
          "kind": "llm",
          "prompt_tokens": 2564,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1287,
          "latency_s": 2.922238826751709
        },
        {
          "step": "turn-5",
          "tool": "query_user_history",
          "kind": "llm",
          "prompt_tokens": 2863,
          "cached_tokens": 0,
          "completion_tokens": 136,
          "tool_ctx_tokens": 1389,
          "latency_s": 2.6890718936920166
        },
        {
          "step": "turn-6",
          "tool": "issue_refund",
          "kind": "llm",
          "prompt_tokens": 3123,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1490,
          "latency_s": 3.073489189147949
        },
        {
          "step": "turn-7",
          "tool": "send_notification",
          "kind": "llm",
          "prompt_tokens": 3408,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1579,
          "latency_s": 3.0861823558807373
        },
        {
          "step": "turn-8",
          "tool": "close_ticket",
          "kind": "llm",
          "prompt_tokens": 3668,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1648,
          "latency_s": 2.504854917526245
        }
      ]
    },
    {
      "key": "kv",
      "name": "KV 仅缓存(稳定前缀/不压缩)",
      "total_cost": 0.0027075,
      "component_costs": {
        "uncached_input_cost": 0.0010227,
        "cached_input_cost": 0.0010176,
        "output_cost": 0.0006672,
        "uncached_input_tokens": 6818,
        "cached_input_tokens": 13568,
        "output_tokens": 1112,
        "tool_ctx_tokens": 9544
      },
      "cost_distribution": {
        "n": 8,
        "mean": 0.0003384375,
        "p50": 0.00033465000000000003,
        "p95": 0.00040815,
        "p99": 0.00040815,
        "max": 0.00040815
      },
      "spans": [
        {
          "step": "turn-1",
          "tool": "query_order",
          "kind": "llm",
          "prompt_tokens": 1056,
          "cached_tokens": 1024,
          "completion_tokens": 121,
          "tool_ctx_tokens": 276,
          "latency_s": 2.1304330825805664
        },
        {
          "step": "turn-2",
          "tool": "query_logistics",
          "kind": "llm",
          "prompt_tokens": 1763,
          "cached_tokens": 0,
          "completion_tokens": 117,
          "tool_ctx_tokens": 829,
          "latency_s": 2.102344036102295
        },
        {
          "step": "turn-3",
          "tool": "check_refund_policy",
          "kind": "llm",
          "prompt_tokens": 2130,
          "cached_tokens": 1024,
          "completion_tokens": 136,
          "tool_ctx_tokens": 1046,
          "latency_s": 3.537381172180176
        },
        {
          "step": "turn-4",
          "tool": "query_knowledge_base",
          "kind": "llm",
          "prompt_tokens": 2540,
          "cached_tokens": 1024,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1287,
          "latency_s": 3.393212080001831
        },
        {
          "step": "turn-5",
          "tool": "query_user_history",
          "kind": "llm",
          "prompt_tokens": 2834,
          "cached_tokens": 2176,
          "completion_tokens": 114,
          "tool_ctx_tokens": 1389,
          "latency_s": 1.906968116760254
        },
        {
          "step": "turn-6",
          "tool": "issue_refund",
          "kind": "llm",
          "prompt_tokens": 3081,
          "cached_tokens": 2560,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1490,
          "latency_s": 2.7878849506378174
        },
        {
          "step": "turn-7",
          "tool": "send_notification",
          "kind": "llm",
          "prompt_tokens": 3361,
          "cached_tokens": 2560,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1579,
          "latency_s": 2.825930118560791
        },
        {
          "step": "turn-8",
          "tool": "close_ticket",
          "kind": "llm",
          "prompt_tokens": 3621,
          "cached_tokens": 3200,
          "completion_tokens": 144,
          "tool_ctx_tokens": 1648,
          "latency_s": 2.7222893238067627
        }
      ]
    },
    {
      "key": "compress",
      "name": "仅压缩(前缀不稳定/摘要)",
      "total_cost": 0.00311475,
      "component_costs": {
        "uncached_input_cost": 0.00242655,
        "cached_input_cost": 0.0,
        "output_cost": 0.0006882,
        "uncached_input_tokens": 16177,
        "cached_input_tokens": 0,
        "output_tokens": 1147,
        "tool_ctx_tokens": 5248
      },
      "cost_distribution": {
        "n": 8,
        "mean": 0.00038934375,
        "p50": 0.0004059,
        "p95": 0.0004497,
        "p99": 0.0004497,
        "max": 0.0004497
      },
      "spans": [
        {
          "step": "turn-1",
          "tool": "query_order",
          "kind": "llm",
          "prompt_tokens": 1112,
          "cached_tokens": 0,
          "completion_tokens": 127,
          "tool_ctx_tokens": 276,
          "latency_s": 2.223604917526245
        },
        {
          "step": "turn-2",
          "tool": "query_logistics",
          "kind": "llm",
          "prompt_tokens": 1829,
          "cached_tokens": 0,
          "completion_tokens": 91,
          "tool_ctx_tokens": 829,
          "latency_s": 2.916440963745117
        },
        {
          "step": "turn-3",
          "tool": "check_refund_policy",
          "kind": "llm",
          "prompt_tokens": 2164,
          "cached_tokens": 0,
          "completion_tokens": 129,
          "tool_ctx_tokens": 1046,
          "latency_s": 2.543590784072876
        },
        {
          "step": "turn-4",
          "tool": "query_knowledge_base",
          "kind": "llm",
          "prompt_tokens": 2310,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1052,
          "latency_s": 2.999290943145752
        },
        {
          "step": "turn-5",
          "tool": "query_user_history",
          "kind": "llm",
          "prompt_tokens": 2066,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 635,
          "latency_s": 5.8334801197052
        },
        {
          "step": "turn-6",
          "tool": "issue_refund",
          "kind": "llm",
          "prompt_tokens": 2146,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 551,
          "latency_s": 2.787147045135498
        },
        {
          "step": "turn-7",
          "tool": "send_notification",
          "kind": "llm",
          "prompt_tokens": 2192,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 430,
          "latency_s": 3.470608949661255
        },
        {
          "step": "turn-8",
          "tool": "close_ticket",
          "kind": "llm",
          "prompt_tokens": 2358,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 429,
          "latency_s": 2.5834107398986816
        }
      ]
    },
    {
      "key": "both",
      "name": "B 优化(KV缓存+压缩)",
      "total_cost": 0.00264285,
      "component_costs": {
        "uncached_input_cost": 0.00148365,
        "cached_input_cost": 0.0004608,
        "output_cost": 0.0006984000000000001,
        "uncached_input_tokens": 9891,
        "cached_input_tokens": 6144,
        "output_tokens": 1164,
        "tool_ctx_tokens": 5248
      },
      "cost_distribution": {
        "n": 8,
        "mean": 0.00033035625,
        "p50": 0.00033525000000000004,
        "p95": 0.00044249999999999997,
        "p99": 0.00044249999999999997,
        "max": 0.00044249999999999997
      },
      "spans": [
        {
          "step": "turn-1",
          "tool": "query_order",
          "kind": "llm",
          "prompt_tokens": 1056,
          "cached_tokens": 1024,
          "completion_tokens": 139,
          "tool_ctx_tokens": 276,
          "latency_s": 2.405411958694458
        },
        {
          "step": "turn-2",
          "tool": "query_logistics",
          "kind": "llm",
          "prompt_tokens": 1781,
          "cached_tokens": 0,
          "completion_tokens": 112,
          "tool_ctx_tokens": 829,
          "latency_s": 2.1826939582824707
        },
        {
          "step": "turn-3",
          "tool": "check_refund_policy",
          "kind": "llm",
          "prompt_tokens": 2143,
          "cached_tokens": 1024,
          "completion_tokens": 151,
          "tool_ctx_tokens": 1046,
          "latency_s": 2.5647878646850586
        },
        {
          "step": "turn-4",
          "tool": "query_knowledge_base",
          "kind": "llm",
          "prompt_tokens": 2310,
          "cached_tokens": 0,
          "completion_tokens": 160,
          "tool_ctx_tokens": 1052,
          "latency_s": 3.0318493843078613
        },
        {
          "step": "turn-5",
          "tool": "query_user_history",
          "kind": "llm",
          "prompt_tokens": 2060,
          "cached_tokens": 1024,
          "completion_tokens": 160,
          "tool_ctx_tokens": 635,
          "latency_s": 2.475983142852783
        },
        {
          "step": "turn-6",
          "tool": "issue_refund",
          "kind": "llm",
          "prompt_tokens": 2143,
          "cached_tokens": 1024,
          "completion_tokens": 160,
          "tool_ctx_tokens": 551,
          "latency_s": 2.7105050086975098
        },
        {
          "step": "turn-7",
          "tool": "send_notification",
          "kind": "llm",
          "prompt_tokens": 2188,
          "cached_tokens": 1024,
          "completion_tokens": 160,
          "tool_ctx_tokens": 430,
          "latency_s": 2.7884562015533447
        },
        {
          "step": "turn-8",
          "tool": "close_ticket",
          "kind": "llm",
          "prompt_tokens": 2354,
          "cached_tokens": 1024,
          "completion_tokens": 122,
          "tool_ctx_tokens": 429,
          "latency_s": 2.3781700134277344
        }
      ]
    }
  ]
}