跳到主要内容

02 · Agent、Runner、Tracer 与奖励

本章讲什么: 上一章的账本是「静态骨架」,这一章是「执行侧怎么真的把一道题跑出来、把 span 生出来」。三个主角:你写的 LitAgent、跑它的 Runner、录它的 Tracer。看完你会明白「零代码改动」的魔法到底藏在哪。

1. 你的 agent:LitAgent

1.1 契约就一个方法

Agent Lightning 对你的 agent 只有一个要求:实现 rollout(task, resources, rollout)(依据:litagent/litagent.py:181)。三个入参:

  • task:这道题的输入(就是 store 里 rollout 的 input)。
  • resources:框架发给你的当前资源(NamedResources,里面有 LLM 端点、提示词模板)。
  • rollout:这趟的元数据(含 rollout_id/attempt_id)。

返回值类型叫 RolloutRawResult有多种合法形态(依据:types/core.py:305-311litagent/litagent.py:194-202):

返回什么含义
None我啥也不返回,遥测全靠 tracer 录(最常见)
一个 float只报最终奖励,其它交给 tracer
List[ReadableSpan]我自己构造好的 OTel span
List[Span] / List[SpanCoreFields]我自己构造好的 Agent Lightning span

还能分训练/验证用不同逻辑:training_rollout / validation_rollout(默认都委托给 rolloutlitagent/litagent.py:220-235),以及对应的 _async 版本。框架会用 is_async() 探测你实现的是同步还是异步版(依据:litagent/litagent.py:75)。

1.2 「零代码改动」的真相:@rollout 装饰器

大多数人不会去继承 LitAgent,而是用装饰器把一个普通函数变成 agent。这就是「几乎零改动」的来源。

examples/calc_x/calc_agent.py:65 的真实用法——一个裸的 async 函数,加一行 @agl.rollout

# 依据 examples/calc_x/calc_agent.py:65-105(已精简)
@agl.rollout
async def calc_agent(task: MathProblem, llm: agl.LLM) -> None:
# 用 llm.endpoint / llm.model 正常调模型解题……
reward = await evaluate(answer, str(task["result"]))
agl.emit_reward(reward) # 报分

装饰器背后是 FunctionalLitAgentlitagent/decorator.py:94)——它把你的函数包成一个 LitAgent。妙处在于它按参数名自动注入:函数签名里写了 llm 就注入 LLM 资源、写了 prompt_template 就注入提示词、写了 rollout 就注入元数据(依据:litagent/decorator.py:180 _get_kwargs)。所以你的函数签名可以很自由,(task, llm)(task, llm, rollout)(task, prompt_template) 都行。

三个装饰器入口(依据:litagent/decorator.py):

装饰器给谁用期望签名
@llm_rollout要调 LLM 的 agent(训权重)(task, llm[, rollout])
@prompt_rollout只优化提示词的 agent(task, prompt_template[, rollout])
@rollout自动识别上面两种看签名里有 llm 还是 prompt_template

还有个细节:装饰器默认 strip_proxy=True,会把 ProxyLLM 自动「拆」成一个把 rollout/attempt 信息烤进 URL 的普通 LLM 再交给你(依据:litagent/decorator.py:265 _strip_proxy_helperresources.py:101 with_attempted_rollout)。这样你的函数只管用 llm.endpoint,路由细节透明。

2. 跑 agent 的人:LitAgentRunner

LitAgentRunnerrunner/agent.py:60)是执行侧的引擎。一个 Runner 就是一个 worker,可以起很多个并行跑(n_runners)。它的生命有两个层次:一个是不停领活的主循环 iter(),一个是执行单趟的 _step_impl()

2.1 主循环 iter()

iter()runner/agent.py:737)就是「不停从 store 领题来做」,直到收到停止信号、做满 max_rollouts、或队列空了。伪代码:

# 示意,对应 runner/agent.py:737 iter()
start_heartbeat_loop() # 后台线程定期给 store 报活
while 没被叫停 and 没做够:
rollout = None
while 没被叫停:
rollout = await store.dequeue_rollout(worker_id) # 领一道题
if rollout is None:
await sleep_until_next_poll() # 队列空,带抖动地等一会儿再问
else:
break
if rollout is None:
return
await self._step_impl(rollout) # 真正跑这一趟

两个工程细节值得注意:

  • 轮询带抖动(jitter)_sleep_until_next_pollpoll_interval ± interval_jitter 之间随机(依据:runner/agent.py:599-619)。目的是打散多个 Runner 的同步节拍,避免它们同时砸向 store。
  • 心跳独立于主循环:默认用两个后台线程(生产者抓系统快照、消费者发给 store)跑心跳(_start_heartbeat_thread_looprunner/agent.py:484),这样心跳不会被卡住的 rollout 阻塞——store 才能靠 last_heartbeat_time 判断这个 worker 是不是真死了。

2.2 单趟执行 _step_impl()

这是最核心的一段(runner/agent.py:621)。一趟 rollout 端到端是这样走的:

① 取资源 ──▶ ② on_rollout_start 钩子 ──▶ ③ 进 tracer 追踪上下文

├─ on_trace_start 钩子
├─ 调 agent 的 rollout 方法(最耗时)★
└─ on_trace_end 钩子

④ 后处理返回值(_post_process_rollout_result) ──▶ ⑤ 算最终奖励

⑥ finally: on_rollout_end 钩子 + 把 attempt 标 succeeded/failed

对应要点:

  1. 取资源runner/agent.py:639-653):任务上有 resources_id 就按它取,没有就取 store 的最新版(get_latest_resources)。取不到资源就跳过这题。
  2. 追踪上下文runner/agent.py:663):async with self._tracer.trace_context(name=rollout_id, rollout_id=..., attempt_id=...)——进了这个 with 块,块内所有 LLM/工具调用产生的 span 都会被自动钉上正确的 rollout_id/attempt_id 并落库。
  3. 调 agentrunner/agent.py:674-691):按 modetraining_rollout 还是 validation_rollout,按 is_async() 选同步/异步。注释直言这是「整个函数最贵的一步」,且如果 agent 卡死,runner 内部无能为力(依据:runner/agent.py:671-673 的注释),得靠执行策略去重启 worker——这是它诚实标注的当前局限。
  4. 标成败runner/agent.py:716-733):无论成功失败,finally 里都会 update_attempt 标一个终态。有异常标 failed,否则 succeeded

2.3 返回值怎么落库:_post_process_rollout_result

上一节说 agent 返回值有多种形态,统一「消化」它们的就是这个方法(runner/agent.py:270)。它按类型分派(依据:runner/agent.py:289-384):

agent 返回Runner 怎么处理
None直接拿 tracer 录到的 span(get_last_trace
float保留已有 span,再把这个数造一个 reward span 补进 store
List[ReadableSpan]逐个 add_otel_span 转换落库
List[Span]直接 add_span
List[SpanCoreFields]先向 store 领序号,再补全成 Span 落库

注意 float 那条分支:它调 emit_reward(raw_result, propagate=False) 造一个奖励 span,再手动 add_span(依据:runner/agent.py:304-313)。所以无论你是 return reward 还是 emit_reward(reward),最终奖励都以「一个 reward span」的形式进 store——这是第 03 章能统一处理奖励的前提。

2.4 单步 API:step()

除了主循环,Runner 还有个 step()runner/agent.py:794)用于绕过队列直接跑一趟——调试和在线场景用。它内部走 start_rollout(立即建 attempt)而不是 dequeue,跑完把完整 rollout 返回,且异常会向上抛(不像 iter() 里吞掉),方便调试定位。calc_agent.py:146debug() 就用它手动跑两道题。

3. 录制器:Tracer

3.1 它解决什么

Tracer(tracer/base.py:27)是后端无关的录制接口。核心就一个上下文管理器 trace_context()tracer/base.py:74):进去开始录、出来收工,块内产生的 span 都被收集,之后能用 get_last_trace() 取回(tracer/base.py:108)。

为什么要抽象成接口?因为不同 agent 框架的遥测来源不同——有的走 AgentOps,有的走原生 OpenTelemetry,有的走 Weave。Agent Lightning 提供了对应实现(依据:tracer/ 目录):

Tracer适配什么文件
AgentOpsTracer默认;靠 AgentOps 自动插桩各大框架tracer/agentops.py
OtelTracer原生 OpenTelemetry;也可当「只录奖励」的轻量 tracertracer/otel.py
WeaveTracerWeave 生态tracer/weave.py

默认是 AgentOpsTracer(依据:trainer/trainer.py:254)。这就是「零代码改动」的另一半——AgentOps 自动给 LangChain/AutoGen/OpenAI SDK 等插桩,你调 LLM 的代码一行不改,调用就被录成 span 了。

3.2 「当前活跃 tracer」的小机关

trace_context 进入时会把自己设成进程级的「活跃 tracer」(set_active_tracertracer/base.py:229),退出时清掉。这样 emit_reward 这类函数不用显式拿到 tracer 引用,直接找「当前活跃的那个」就能把 span 发出去。注意它禁止嵌套——已有活跃 tracer 时再设会报错(tracer/base.py:236-237),防止上下文串味。

4. 奖励:也是一个 span

奖励在 Agent Lightning 里不是特殊通道,而是一种 spanemit_reward(value)emitter/reward.py:148)把一个数(或多维奖励字典)编码成一个带 agentlightning.reward 属性的标注 span(依据:emitter/reward.py:208emit_annotation)。

几个配套函数(都在 emitter/reward.py):

  • get_reward_value(span)(:213):从一个 span 里把奖励值抠出来——同时兼容 v0.3+ 新格式、旧 AgentOps 格式、v0.2 老格式,做了多版本容错。
  • find_final_reward(spans)(:307):从后往前找第一个带奖励的 span,作为「最终奖励」。Runner 跑完就用它算这趟得分并打印(依据:runner/agent.py:701)。

多维奖励也支持:emit_reward({"task_completion": 1.0, "efficiency": 0.8}, primary_key="task_completion")——第一维是主奖励,其余是附加维度(依据:emitter/reward.py:184-200)。

注意历史包袱: agentlightning/reward.py 只是个转发到 emitter 的废弃 shim,import 就会 warn(依据:reward.py:7)。新代码应从 agentlightning.emitter 取。


小结: 执行侧是一条流水线——Runner 领题 → 进 Tracer 上下文 → 调你的 LitAgent(@rollout 让普通函数无痛接入)→ 运行期被录成 span、奖励也录成 span → 后处理落库 → 标成败。所有产出统一沉淀成 store 里的 span。下一章讲学习侧怎么把这堆 span 变成 RL 能吃的训练样本——这是最巧的一环。