跳到主要内容

03 · 从 span 到 triplet 与「token id 之战」

本章讲什么: 这是 Agent Lightning 含金量最高的一章。Runner 沉淀下来的是一堆乱序、混杂、多框架混血的 span;RL 算法要的是干净的 (提示, 回答, 奖励) 训练样本。把前者变后者,是这个项目最难也最巧的活。外加一个隐藏 boss——「token id 漂移」,不解决它,RL 会学歪却查不出原因。

1. 目标:triplet 是什么

先看终点。Triplettypes/core.py:69)就三个字段:promptresponsereward。一段 agent 轨迹会产出一串 triplet——每次「模型被调用一次」就是一条,reward 是配给这次调用的分数。这正是 RL 的标准食材:状态(prompt)、动作(response)、回报(reward)。

难点有三个,逐个攻破:

难点白话
① 结构乱span 是扁平一串,还可能父子关系错乱,得先还原成正确的调用树
② 挑得准一个多 agent 系统里 span 混杂,只能挑「我要训的那个 agent」的 LLM 调用
③ 配得对奖励往往只在最后打一次,要合理地「回填」给之前的哪几次调用(信用分配)

主力实现是 TracerTraceToTripletadapter/triplet.py:771),它的 adapt(spans)(:831)就干这三件事。

2. 第一步:把 span 拼成树(TraceTree)

2.1 建树

TraceTree.from_spansadapter/triplet.py:226)把扁平 span 列表按 parent_id 指针接成树。它还处理两种脏情况(依据:adapter/triplet.py:257-309):

  • 顶层会话 span 丢失:有些 span 的 parent_id 指向一个不在列表里的节点,就为它造一个「虚拟父节点」补上。
  • 多个根:如果冒出好几个根,再造一个 virtual-root 把它们兜住,保证最终是一棵树。

2.2 修层级:repair_hierarchy —— 一个真实世界的补丁

这是最能体现「读真源码才知道的坑」的地方。repair_hierarchyadapter/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_callsadapter/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 SDKagent.name
AgentOps @agent 装饰器agentops.span.kind == "agent" 时取 operation.name
AutoGenrecipient_agent_type
LangGraphlangchain.chain.type
agent-frameworkexecutor.id
Weave / Weave+LangChainagentlightning.operation.input.name / lc_name

这张表本身就是这个项目「兼容任何框架」承诺的落地证据——它不是靠魔法,而是靠老老实实为每个生态写一条适配规则

4. 第三步:把奖励配给调用(信用分配)

奖励通常稀疏——一趟下来可能只在最后 emit_reward 一次。可轨迹里有好几次 LLM 调用,这一分该算谁的?这是 RL 的经典「信用分配」问题。Agent Lightning 给了两种策略(RewardMatchPolicyadapter/triplet.py:84):

策略规则直觉
FIRST_OCCURRENCE(默认)每个奖励 span,回填给它之前、时间上最近、还没被配过的那次 LLM 调用「这次打分是对刚才那步的反馈」
FIRST_SIBLING奖励配给同一父节点下的前一个兄弟 LLM 调用「打分和调用是同一层的搭档」

实现见 match_rewardsadapter/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_tripletadapter/triplet.py:631)从一个 LLM 调用 span 里刨出四样东西塞进 triplet:

  • prompt / response 的 token id:从多个候选属性名里找(prompt_token_idsresponse_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。这靠 LLMProxyllm_proxy.py:1007)实现:它是一个架在你的 agent 和真实模型之间的 OpenAI 兼容代理,做几件事(依据:llm_proxy.py:1007-1030 类 docstring):

  1. 转发 OpenAI 兼容请求到底层 vLLM。
  2. 用中间件给请求打上 rollout/attempt 路由信息。
  3. 注册回调让响应带回 token id——AddReturnTokenIds 回调直接往请求里注入 return_token_ids=True(依据:llm_proxy.py:149-175),vLLM 于是把 prompt_token_ids/response_token_ids 原样回传。
  4. 把这些经 OTel 导出成 span。

然后专门有个适配器 LlmProxyTraceToTripletadapter/triplet.py:857)从代理产生的 span 里读这些原始 token id(如 llm.hosted_vllm.response_token_idsadapter/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 后怎么真正训。