03 · 从 span 到 triplet 与「token id 之战」
本章讲什么: 这是 Agent Lightning 含金量最高的一章。Runner 沉淀下来的是一堆乱序、混杂、多框架混血的 span;RL 算法要的是干净的 (提示, 回答, 奖励) 训练样本。把前者变后者,是这个项目最难也最巧的活。外加一个隐藏 boss——「token id 漂移」,不解决它,RL 会学歪却查不出原因。
1. 目标:triplet 是什么
先看终点。Triplet(types/core.py:69)就三个字段:prompt、response、reward。一段 agent 轨迹会产出一串 triplet——每次「模型被调用一次」就是一条,reward 是配给这次调用的分数。这正是 RL 的标准食材:状态(prompt)、动作(response)、回报(reward)。
难点有三个,逐个攻破:
| 难点 | 白话 |
|---|---|
| ① 结构乱 | span 是扁平一串,还可能父子关系错乱,得先还原成正确的调用树 |
| ② 挑得准 | 一个多 agent 系统里 span 混杂,只能挑「我要训的那个 agent」的 LLM 调用 |
| ③ 配得对 | 奖励往往只在最后打一次,要合理地「回填」给之前的哪几次调用(信用分配) |
主力实现是 TracerTraceToTriplet(adapter/triplet.py:771),它的 adapt(spans)(:831)就干这三件事。
2. 第一步:把 span 拼成树(TraceTree)
2.1 建树
TraceTree.from_spans(adapter/triplet.py:226)把扁平 span 列表按 parent_id 指针接成树。它还处理两种脏情况(依据:adapter/triplet.py:257-309):
- 顶层会话 span 丢失:有些 span 的
parent_id指向一个不在列表里的节点,就为它造一个「虚拟父节点」补上。 - 多个根:如果冒出好几个根,再造一个
virtual-root把它们兜住,保证最终是一棵树。
2.2 修层级:repair_hierarchy —— 一个真实世界的补丁
这是最能体现「读真源码才知道的坑」的地方。repair_hierarchy(adapter/triplet.py:478)解决的问题是:有些框架用多个子系统各自发 span,导致 LLM 补全 span 直接飘到根节点下,而不是挂在正确的 agent 节点下(依据:adapter/triplet.py:483-487 注释)。
后果很实在:如果不修,等你 想「按 agent 过滤、只要某 agent 名下的 LLM 调用」时,那些飘走的 span 永远选不中。
修法是按时间包含关系重新认爹:对每个需要修的节点,在全树里找一个「时间上完全包住它、且时长最接近」的节点当新父亲(依据:adapter/triplet.py:499-519)。直觉是——真正的父调用一定在时间上完整地覆盖子调用,且覆盖得最紧的那个最可能是直接父亲。
它还特判了一种情况:AgentOps 在手动结束 trace 时会自动多包一层合成根节点(如 run_one.session),所以单孩子时直接下钻(adapter/triplet.py:493-495)。
3. 第二步:挑出「该训练的」LLM 调用
3.1 三重过滤
find_llm_calls(adapter/triplet.py:398)在树上递归,用三把尺子筛 span:
| 过滤器 | 作用 |
|---|---|
llm_call_match | 正则匹配 span 名,默认 openai\.chat\.completion——只认 LLM 补全调用 |
agent_match | 正则匹配所在 agent 子树的名字——只要「我关心的那个 agent」名下的调用 |
dedup(response_id 去重) | 同一个 LLM 响应 id 只算一次,避免重复 span 灌进训练 |
agent_match 是「选择性训练某个 agent」能力的技术底座(回应 README 的 “Selectively optimize one or more agents”)。它靠递归时传递一个「当前在不在匹配的 agent 子树里」的标记 within_matching_subtree 实现(依据:adapter/triplet.py:454-459)。
3.2 认 agent 名字:一场「适配器大会」
怎么知道一个 span 属于哪个 agent?agent_name()(adapter/triplet.py:317)里是一长串 if,因为每个框架把 agent 名字塞在不同属性里(依据:adapter/triplet.py:327-365):
| 来源框架 | agent 名字藏在哪个属性 |
|---|---|
| OpenAI Agents SDK | agent.name |
AgentOps @agent 装饰器 | agentops.span.kind == "agent" 时取 operation.name |
| AutoGen | recipient_agent_type |
| LangGraph | langchain.chain.type |
| agent-framework | executor.id |
| Weave / Weave+LangChain | agentlightning.operation.input.name / lc_name |
这张表本身就是这个项目「兼容任何框架」承诺的落地证据——它不是靠魔法,而是靠老老实实为每个生态写一条适配规则。
4. 第三步:把奖励配给调用(信用分配 )
奖励通常稀疏——一趟下来可能只在最后 emit_reward 一次。可轨迹里有好几次 LLM 调用,这一分该算谁的?这是 RL 的经典「信用分配」问题。Agent Lightning 给了两种策略(RewardMatchPolicy,adapter/triplet.py:84):
| 策略 | 规则 | 直觉 |
|---|---|---|
FIRST_OCCURRENCE(默认) | 每个奖励 span,回填给它之前、时间上最近、还没被配过的那次 LLM 调用 | 「这次打分是对刚才那步的反馈」 |
FIRST_SIBLING | 奖励配给同一父节点下的前一个兄弟 LLM 调用 | 「打分和调用是同一层的搭档」 |
实现见 match_rewards(adapter/triplet.py:522)。关键约束:奖励只能配给「在它之前结束」的调用(assign_to_end_time > item.start_time 就跳过,依据:adapter/triplet.py:548),保证因果——不能拿未来的分去评价过去的动作。
还有个便捷口:to_trajectory(..., final_reward=X) 能把一个「最终总分」直接盖到最后一条 triplet 上(依据:adapter/triplet.py:755-757)。
整条流水线串起来
乱序 spans
│ from_spans 拼成树
▼
TraceTree ── repair_hierarchy ──▶ 层级修好的树
│ find_llm_calls 三重过滤:LLM调用 ∧ 目标agent ∧ 去重
▼
[被选中的 LLM 调用]
│ span_to_triplet 每个调用 → (prompt, response) ,先不带 reward
│ match_rewards 按策略把 reward 回填
▼
[Triplet(prompt, response, reward), ...] ← 喂给 RL
(依据:adapter/triplet.py:702-758 to_trajectory。)
5. span 里到底提取什么:span_to_triplet
span_to_triplet(adapter/triplet.py:631)从一个 LLM 调用 span 里刨出四样东西塞进 triplet:
- prompt / response 的 token id:从多个候选属性名里找(
prompt_token_ids、response_token_ids,以及各种 Weave/新 vLLM 变体,依据:adapter/triplet.py:637-658)。 - raw_content:提示词和补全的原始消息内容。
- image_urls:多模态时从消息里抽图片 URL(
extract_prompt_image_urls,:577)。 - response_id / 请求响应元数据 / logprobs:辅助信息。
注意那句 “从多个候选属性名里找 token id”——这引出本章的隐藏 boss。
6. 隐藏 boss:token id 之战
6.1 问题:重分词漂移
RL 训语言模型,梯度是在 token 序列上算的。理论上很简单:模型吐出的 token 序列就是「动作」。但现实里有个坑:
- 你的 agent 调的是 OpenAI 兼容 API,它返回的是文本。
- 要拿去训练,你得把文本再分词(tokenize)成 token id。
- 麻烦在于:你重新分出来的 token id,可能和推理引擎(vLLM)当初实际采样的 token id 不一样——同一段文本可以有多种合法分词。
这就是「retokenization drift(重分词漂移)」。后果是 RL 在「错误的动作序列」上算梯度,训练悄悄学歪,而且极难排查。项目专门为此写过 vLLM 官方博客(依据:README.md:51 “No More Retokenization Drift”)。
6.2 解法:让引擎直接回传 token id
Agent Lightning 的做法是——别重新分词,直接要引擎当初用的原始 token id。这靠 LLMProxy(llm_proxy.py:1007)实现:它是一个架在你的 agent 和真实模型之间的 OpenAI 兼容代理,做几件事(依据:llm_proxy.py:1007-1030 类 docstring):
- 转发 OpenAI 兼容请求到底层 vLLM。
- 用中间件给请求打上 rollout/attempt 路由信息。
- 注册回调让响应带回 token id——
AddReturnTokenIds回调直接往请求里注入return_token_ids=True(依据:llm_proxy.py:149-175),vLLM 于是把prompt_token_ids/response_token_ids原样回传。 - 把这些经 OTel 导出成 span。
然后专门有个适配器 LlmProxyTraceToTriplet(adapter/triplet.py:857)从代理产生的 span 里读这些原始 token id(如 llm.hosted_vllm.response_token_ids,adapter/triplet.py:887-922)。这个适配器有个重要纪律:只信 sequence_id 排序,绝不信时间 戳——因为代理 span 可能来自时钟不同步的多台机器(依据:adapter/triplet.py:864-867 的 danger 提示)。
一句话记住这场仗: RL 的动作必须是模型「真正吐出的那串 token」,不能是「你事后猜的那串」。LLMProxy 的价值就是把真 token id 原路带回来。这是把「训 agent」做对的关键工程,不是锦上添花。
6.3 一个警告:流式的取舍
代理默认会把 stream=True 的请求先转成非流式再走 LiteLLM 代理,因为 LiteLLM 的 OTel tracer 处理流式有 bug;代价是——如果你关掉这个 stream_conversion 中间件,可能就丢了 token id 这类追踪信息(依据:llm_proxy.py:1030-1034 的 warning)。这是它诚实标注的一个取舍。
小结: 这一章是「脏数据 → 干净训练样本」的炼金术。三步——拼树修层级 → 三重过滤挑对 agent 的 LLM 调用 → 按因果策略配奖励——产出 triplet;再靠 LLMProxy 回传原始 token id 堵住重分词漂移这个隐蔽杀手。下一章看学习侧的算法拿到 triplet 后怎么真正训。