LLM Agent 的持续强化学习,一直是让中小团队头疼的事——想提效果就得训模型,想训模型就得堆卡,堆完卡还得调参,一圈下来预算和时间全烧在试错上。最近有一篇被某机器学习顶会收录为 Oral 的论文提出了一个叫JitRL的思路,标题里那句“98 美元替代 3500 美元 GPU 训练”很抓眼球。它的核心主张是:Agent 的能力提升不必走梯度更新这条路,把强化学习挪到推理阶段、让策略以文本形式沉淀下来,同样能跑出持续学习的闭环。这个思路对预算有限、又想让 Agent 在实际任务里越用越好的团队来说,参考价值相当大。下面我按自己的理解把这套方法拆开讲,顺带把能直接抄的最小实现和踩坑点一起写出来,不管你是刚接触 Agent 的新手,还是已经在做 RLHF 的老手,都能拿到点东西。
1. 先把问题摆正:JitRL 到底在解决什么
1.1 传统 Agent 强化学习的钱,一大半花在“不该花”的地方
想让一个 Agent 在真实任务上稳定提升,常规做法是收集轨迹、算优势、做策略梯度更新。这条路技术上成熟,但成本结构非常难看。我拿一个 7B 量级的模型举例,走 PPO 或者 GRPO 这类路线,硬件上至少需要一组 80GB 显存档位的训练卡常驻,因为你要同时装下策略模型、参考模型、优化器状态,还要留出 rollout 的显存余量。租用成本按小时算,一轮完整训练加验证,几十小时是常态,账单四位数美元起步。
更麻烦的是隐性成本。第一是调参返工,学习率、KL 系数、batch 组织方式、奖励归一化,任何一个设歪了都要重跑,而每次重跑都是真金白银。第二是环境搭建,Agent 任务往往涉及多轮工具调用、外部状态变更,要把这些包装成可并行、可复现的训练环境,工程量不比模型训练本身小。第三是奖励工程,你很难给“这个回答是否解决了用户问题”写一个可微的奖励函数,最后还是要靠规则或者另一个模型打分,而这一步在传统 RL 里是前置的、一次性的,打得不准就直接把策略带偏。
所以真实情况是:一个团队花 3000 多美元,最后拿到的可能只是一个在特定分布上稍微好一点的 checkpoint,换个任务场景又得重来。这个投入产出比,对绝大多数非大厂团队来说是不成立的。
1.2 换一个问法:Agent 的能力一定要存在权重里吗
JitRL 最有价值的地方不是某个具体算法,而是它把问题重新问了一遍。一个 Agent 表现好不好,能力其实存在三个地方:模型权重、上下文里的指令与示例、外部可检索的记忆与工具。传统强化学习只动第一个,而工业界早就发现后两个的杠杆率极高——同一个基座模型,提示词写得好不好,任务成功率能差出二三十个点。
这就引出一个很自然的想法:既然改上下文就能显著改变行为,那为什么不把“强化学习”这件事直接作用在上下文上?把策略的搜索空间从“几亿个浮点参数”换成“几百条自然语言规则”,把梯度下降换成“采样、打分、筛选、归纳”,整个训练流程就不需要反向传播了,也就不需要训练卡常驻。
打个比方,传统 RL 像是把员工送去重新读一遍大学,改的是他的大脑结构;JitRL 像是给员工配一本不断增厚的岗位手册,遇到新情况就翻手册、试新招、把有效的招记下来。手册写得越厚越准,员工的表现就越好,但员工本人没变。这个类比不完美,但它解释了为什么“无梯度更新”不等于“不学习”。
1.3 98 美元和 3500 美元,这笔账到底怎么算
标题里的两个数字很容易被误读,我得把口径说清楚。3500 美元那一侧,通常对应的是“租用多卡训练集群、跑完一轮包含多次迭代的完整 RL 训练”,里面包含了无效迭代和调参浪费。98 美元那一侧,对应的是“只调推理接口、跑若干轮经验采集与打分”,成本几乎全部是 token 费用。
具体拆开看:假设一次任务尝试平均消耗 4000 个输入 token 加 800 个输出 token,采样 8 条候选、跑 200 个任务、迭代 5 轮,总调用量大概在千万 token 级别。按当前主流商用接口的价格,几十到一百多美元的区间是合理的。再加上打分环节用的小模型调用,总盘子控制在 100 美元上下并不夸张。
注意:这两个数字不是“同等效果下的成本对比”,而是“达到某个可接受效果水位所需的成本对比”。JitRL 在某些任务上能追平甚至超过梯度训练的结果,在另一些任务上会明显落后。把它理解成“低成本快速试错的一条新路径”比理解成“训练卡的替代品”要准确得多。
我个人的判断是,这套方法真正的价值在于把试错成本压到了一顿外卖的量级。你可以用极低的代价先验证“这个任务到底值不值得投入更多”,验证通过了再去考虑要不要上真正的权重训练。这个决策顺序的调整,比省下来的那几千美元更重要。
2. 核心机制:无梯度更新的强化学习怎么跑起来
2.1 把“参数搜索”换成“策略文本搜索”
JitRL 的整个循环可以概括成四步:采样、打分、蒸馏、复用。给定一个任务,Agent 在注入当前经验库的情况下生成 N 条候选轨迹;打分器给每条轨迹一个分数;把高分轨迹和低分轨迹做对比,提炼出“下次应该怎么做”的规则文本;规则经过准入判断后进入经验库,下一轮任务开始时被检索注入。
这里的关键设计在于搜索空间的定义。传统 RL 搜索的是参数空间,梯度告诉你怎么走;JitRL 搜索的是自然语言规则空间,没有梯度可用,所以必须靠“候选生成加相对比较”来近似梯度方向。具体做法是让模型同时看到成功样本和失败样本,问它“这两条轨迹的差异在哪里,哪条做法是可以复用的”,让模型自己完成差分归纳。这一步本质上是用模型的归纳能力替代了反向传播的信用分配。
为什么这个替代能成立?因为 LLM 本身就是在海量文本上训练出来的模式归纳器,给它几组对照样本让它总结规律,是它最擅长的事情之一。相比之下,让它从稀疏的标量奖励里学到一个策略,反而是它的弱项。JitRL 相当于把学习任务交给了模型更擅长的能力通道。
2.2 经验库:Agent 的“外挂长期记忆”怎么设计
经验库是整个方法的地基。设计得粗糙,几轮之后就会变成一堆互相矛盾、检索不准、越用越差的垃圾文本。我在复现时把每条经验设计成下面这些字段:
| 字段 | 作用 | 设计要点 |
|---|---|---|
exp_id | 唯一标识 | 用规则文本的哈希,天然去重 |
task_sig | 任务签名 | 用于检索匹配,建议用任务类型加关键实体,不要用完整原文 |
rule | 可执行规则 | 必须是祈使句、可直接塞进提示词,禁止写成分析报告 |
evidence | 支撑证据 | 一两条原始轨迹的摘要,用于人工审计和后续再蒸馏 |
score | 入库得分 | 入库时的平均分,作为冷启动的先验 |
uses/wins | 使用次数与胜出次数 | 支撑置信区间估计与淘汰决策 |
created_at/last_used | 时间戳 | 用于老化淘汰 |
task_sig的设计特别值得多说两句。很多人第一反应是拿任务文本做向量化,然后按余弦相似度检索。这在任务高度同质的时候好使,但一旦任务类型混杂,向量相似度会失灵——两个任务字面很像,但一个是“改代码”、一个是“改文案”,需要的规则完全不同。我的做法是两级签名:第一级是任务类型标签(分类、抽取、多步工具调用等),第二级是关键实体或约束条件的集合。检索时先按第一级硬过滤,再在候选集里算相似度。这样召回精度会稳很多。
2.3 信用分配:没有梯度,怎么知道哪一步做对了
这是整套方法里最难的部分,也是最容易被做歪的地方。轨迹级打分只能告诉你“这次成了还是没成”,但一条轨迹可能有十几步,到底是第三步的检索用对了,还是第七步的格式没出错,光看总分是看不出来的。
我试过三种归因方式,效果差别很大。第一种是整条轨迹一起喂给模型让它自己找差异,成本最低,但模型容易被表面差异带偏,经常总结出“要多思考”这种没用的废话。第二种是步骤级打分,让打分器对每个中间步骤单独给分,再取分数波动最大的步骤作为关键点,精度高但调用成本翻好几倍。第三种是反事实对比,把成功轨迹的某一步替换成失败轨迹对应的步骤,看整体分数掉多少,掉得多说明这一步关键。这种方式最准,但需要重跑轨迹,成本最高。
实测下来我推荐一个折中方案:先用整条对比做粗筛,只对分数差距大的样本对做步骤级定位。也就是说,只有成功轨迹和失败轨迹的分差超过阈值时,才值得花额外成本去做精细归因;分差本来就不大的样本对,说明差异是噪声,直接跳过。这个策略能把归因成本压到原来的三分之一左右,精度损失很小。
2.4 防止经验库退化:三道必须设的闸门
经验库有个很讨厌的特性:它会自我强化。一条规则一旦入库,后续任务就会被它影响,如果这条规则其实是过拟合的,它会持续制造同类失败,而这些失败又会被误读成“这条规则不够强”,于是继续加码。跑上十几轮,整个库就被几条错误规则绑架了。
第一道闸门是准入。规则必须在至少两个不同的任务实例上验证有效才能入库,单次成功的样本一律当噪声处理。第二道闸门是淘汰。给每条规则维护使用次数和胜出次数,用置信下界做排序,连续多轮使用但胜率低于基线的规则直接下线,不要舍不得。第三道闸门是冲突消解。新规则入库前先和库里的高相似规则做比对,如果语义高度重叠但结论相反,不要两条都留,而是生成一个“在什么条件下用哪条”的元规则,把冲突显式化。
提示:这三道闸门里,第二道最容易被忽略。很多人建库很积极,清理很消极,结果库越滚越大,检索命中率越来越低,最后模型被一堆低质量上下文拖垮。我的习惯是每轮结束后固定跑一次清理脚本,把胜率排在末位的 10% 强制下线,宁可少几条,也不要留垃圾。
3. 动手实现:一套最小可跑的 JitRL 骨架
3.1 环境准备与目录结构
这套东西对硬件几乎没有要求,一台普通开发机就够,因为所有计算都在远端推理接口上完成。依赖也很简单:一个 HTTP 客户端、一个向量或关键词检索引擎、一个结构化存储。我不建议一上来就上向量数据库,先用 JSONL 加关键词匹配跑通闭环,等库超过几千条再考虑换。
目录我习惯这么组织:
jitrl/ ├── exp_store.py # 经验条目定义与读写 ├── retriever.py # 两级签名检索 ├── scorer.py # 打分器组合 ├── distiller.py # 轨迹对比与规则蒸馏 ├── agent.py # 单次任务执行 └── loop.py # 主训练循环把打分、蒸馏、执行拆成独立模块的好处是,每一块都能单独换实现。比如打分器一开始用规则,后来想换成模型评判,只要接口不变,主循环一行都不用改。
3.2 经验条目的数据结构
import json import hashlib import time from dataclasses import dataclass, asdict, field @dataclass class Experience: exp_id: str task_type: str # 一级签名:任务类型 task_keys: list # 二级签名:关键实体与约束 rule: str # 祈使句形式,可直接注入提示词 evidence: str # 原始轨迹摘要,便于审计 score: float # 入库时的平均分 uses: int = 0 wins: int = 0 created_at: float = field(default_factory=time.time) last_used: float = 0.0 @staticmethod def make_id(rule: str) -> str: return hashlib.md5(rule.strip().encode("utf-8")).hexdigest()[:12] def load_pool(path: str) -> list: pool = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: pool.append(Experience(**json.loads(line))) return pool def append_pool(path: str, exp: Experience) -> None: with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(asdict(exp), ensure_ascii=False) + "\n")用哈希做 ID 是个小技巧,它天然实现了“同一条规则不会重复入库”。前提是入库前对规则文本做统一清洗:去首尾空白、统一标点、去掉“我们”“应该”这类无意义前缀。清洗做得好,去重效果能提升一大截。
3.3 打分器:规则分和模型分的加权组合
打分器是决定整个系统上限的部件。我的经验是优先把能写成规则的硬指标做扎实,模型评判只做兜底。原因很实在:规则分稳定、可复现、零成本;模型分有方差、有偏见、要花钱。一个任务如果连硬指标都定义不出来,说明它本身还不适合用这套方法优化。
def score_trajectory(traj, task, judge_client, weights=(0.7, 0.3)): # 硬指标:可程序化验证的部分,比如格式合法、字段齐全、数值在区间内 hard = hard_check(traj, task) # 返回 0~1 # 软指标:交给模型评判,只问一个具体问题,不要让它自由发挥 soft = judge_client.rate( instruction="判断该回答是否完整解决了任务描述中的全部要求,只输出 0 到 10 的整数", context=task.text, answer=traj.final_output, ) / 10.0 w_hard, w_soft = weights return w_hard * hard + w_soft * soft这里有个细节值得强调:给模型评判的指令必须极度具体,绝对不能写成“评价这个回答好不好”。我见过太多人在这上面翻车,评判指令一模糊,分数就变成模型心情的映射,同一份输出跑两次给两个分,整个训练循环的信号全是噪声。正确的做法是把评判维度拆到最细,一次只问一件事,比如“是否遗漏了任务里的第几个要求”“数字是否正确”“格式是否符合规定”。
3.4 检索与注入:用 UCB 平衡“稳妥”和“探索”
经验库检索不是简单的相似度排序。相似度高但从来没被验证过的规则,和相似度略低但胜率稳定的规则,哪个更值得注入?这是典型的探索利用权衡问题,用多臂老虎机的 UCB 公式处理很合适。
import math def select_experiences(task, pool, top_k=3, c=0.5): total_uses = sum(e.uses for e in pool) + 1 cands = [ e for e in pool if e.task_type == task.type and overlap(e.task_keys, task.keys) >= 0.5 ] def ucb(e): avg = e.wins / e.uses if e.uses else e.score bonus = c * math.sqrt(math.log(total_uses) / (e.uses + 1)) return avg + bonus cands.sort(key=ucb, reverse=True) return cands[:top_k]top_k不建议开大。我实测下来 3 到 5 条是最优区间,再往上加,规则之间开始互相干扰,模型会顾此失彼。而且注入的经验越多,提示词越长,成本越高,边际收益递减得很明显。有个粗暴但有效的判断方法:如果加到第 6 条规则后分数没有提升,就砍回 4 条。
3.5 主循环:采样、打分、蒸馏、入库
MAX_EPISODE = 200 N_SAMPLE = 8 ADMIT_THRESHOLD = 0.75 PAIR_GAP = 0.25 # 只有分差够大的样本对才做精细归因 for ep in range(MAX_EPISODE): task = task_queue.next() exps = select_experiences(task, pool, top_k=4) prompt = build_prompt(task, exps) trajs = [agent.run(prompt, temperature=0.8) for _ in range(N_SAMPLE)] scored = [(t, score_trajectory(t, task, judge)) for t in trajs] scored.sort(key=lambda x: x[1], reverse=True) worst = (scored[0][0], scored[0][1]) for best_traj, best_score in scored[:3]: if best_score < ADMIT_THRESHOLD: continue if best_score - worst[1] < PAIR_GAP: continue # 分差不明显,差异可能是噪声,跳过 rule = distiller.extract(best_traj, worst[0], task) if not rule or Experience.make_id(rule) in existing_ids: continue if validator.verify(rule, task, min_cases=2): append_pool(POOL_PATH, Experience( exp_id=Experience.make_id(rule), task_type=task.type, task_keys=task.keys, rule=rule, evidence=best_traj.summary, score=best_score, )) cleanup_pool(POOL_PATH, drop_ratio=0.1)这段代码里有几个我认为不能省的设计。PAIR_GAP那个判断很关键,它挡掉了大量“分差本来就是噪声”的无效蒸馏,能省下可观的调用成本。validator.verify里的min_cases=2是我反复调出来的值,只验证一次的话漏网率太高,验证三次以上成本又开始不划算。cleanup_pool放在循环末尾而不是定期跑,是为了让库的状态始终可控,不会出现某几轮突然膨胀的情况。
4. 实测数据与调优:哪些参数真的重要
4.1 一组小规模实测记录
我在一个包含多步信息抽取的任务上跑了两百轮,粗略记录了几个关键指标的变化。起始状态下不做任何注入,任务成功率大概 58%;跑完第一轮经验注入后涨到 66%;第五轮之后进入平台期,稳定在 71% 到 74% 之间波动;经验库规模从 0 涨到 40 条左右,之后基本不再增长,因为清理机制在同步起作用。
成本的构成也很有意思。总花费里,任务执行本身只占四成,打分和蒸馏加起来占了六成。这有点反直觉,因为直觉上执行轨迹是最耗 token 的。原因在于我一开始把打分做得太细,每条轨迹都要跑三次模型评判。后来把硬指标补齐、软指标压到只问一个问题,打分成本直接降了一半多。
| 阶段 | 成功率 | 库规模 | 累计成本(相对值) |
|---|---|---|---|
| 基线无注入 | 58% | 0 | 1.0 |
| 第 1 轮后 | 66% | 11 | 1.3 |
| 第 5 轮后 | 72% | 34 | 2.1 |
| 第 20 轮后 | 74% | 41 | 4.6 |
| 第 50 轮后 | 73% | 38 | 9.8 |
这张表最值得看的是最后两行。第 20 轮之后,成本翻了倍,效果反而略有回落。这不是个例,几乎每次跑长循环都会遇到。原因通常是库里的规则开始互相覆盖,检索时注入了次优组合。所以跑多少轮要提前定死,不要抱着“再跑跑可能更好”的侥幸心理,我一般把轮数卡在效果曲线的拐点之后一点点就停。
4.2 关键参数的影响排序
按我踩坑的顺序排,影响从大到小大概是这几个:
| 参数 | 建议取值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| 每条任务的采样数 | 6~10 | 成本线性涨,收益边际递减 | 候选质量差,蒸馏出噪声 |
| 注入规则条数 | 3~5 | 规则互相干扰,提示词膨胀 | 复用不足,学不到东西 |
| 入库分数阈值 | 0.7~0.8 | 库增长慢,可能漏掉好规则 | 垃圾规则混入,污染库 |
| 精细归因的分差门槛 | 0.2~0.3 | 蒸馏次数少,学习慢 | 大量无效蒸馏,纯烧钱 |
| 每轮淘汰比例 | 5%~10% | 好规则被误杀,库震荡 | 库缓慢膨胀,检索变差 |
采样数这一项我想多说一句。很多人第一反应是“采样越多越好”,实际上采样数超过 10 之后,候选轨迹的同质化会非常严重,模型在相同提示下反复生成换汤不换药的东西。这时候继续加采样纯属浪费,真正有效的手段是提高采样温度,把分布的尾部打开。但温度调太高又会引入大量废轨迹,所以两者要配合着调,我一般是先定温度在 0.7 到 0.9,再看候选多样性决定采样数。
4.3 什么任务适合,什么任务别碰
这套方法有个很清晰的适用边界:它优化的对象是“怎么做”这类程序性知识,不是“知不知道”这类事实性知识。
适合的场景长这样:任务有明确的可验证结果,有多种解法且效果差异大,同一个任务类型会反复出现。比如格式规范化、多步检索的组织方式、工具调用的顺序编排、报告结构的搭建,这些都属于“方法定了效果就上去了”的类型。
不适合的场景也很明确。第一类是知识密集型任务,模型不知道某个事实,你写一百条规则它还是不知道,这时候该做的是检索增强或者微调,不是强化学习。第二类是主观评价任务,好坏没有稳定标准,打分器自己都摇摆,整个循环就没有可靠信号。第三类是低频任务,同一类型一年遇到三五次,经验库还没来得及积累就过时了。
注意:判断一个任务适不适合,我有个简单的自测方法——找两个熟悉业务的人,各自独立写一份“做好这个任务的要点清单”,如果两份清单有大面积重合,说明这个方法有明确的优化空间;如果两份清单各说各的,说明任务本身的标准就是模糊的,套这套框架大概率白花钱。
5. 踩坑实录与排查速查表
5.1 五个最典型的故障
故障一:跑了几十轮,成功率纹丝不动。最可能的原因是打分器没有区分度。排查方法很直接:把每轮的轨迹分数打出来看分布,如果所有分数都挤在 0.7 到 0.8 之间,说明打分器根本没在区分好坏。这时候要做的是给硬指标加区分度,比如把“格式合法”这种二值判断拆成“必填字段缺失几个”“格式错误有几处”,让分数真正拉开差距。
故障二:库里规则越来越多,效果却越来越差。这是典型的检索污染。诊断方法是做一次消融,分别只注入排名第 1 的规则、只注入第 1 到 3 的规则、全部注入,看三组效果。如果只有第 1 条好使,说明后面的全是干扰项,问题出在检索排序上而不是规则质量上。修法是收紧top_k并且提高任务签名的匹配阈值。
故障三:规则写得很漂亮,模型就是不照做。问题多半出在规则的表述形式上。分析性表述比如“注意到在某些情况下应该考虑……”,模型基本会忽略;只有祈使句加具体条件的表述才会被执行,比如“当输入包含日期时,先统一成 YYYY-MM-DD 再处理”。蒸馏环节的提示词里必须强制要求输出祈使句,必要时加一步格式校验,不符合的直接打回。
故障四:成本远超预期,一天烧掉几十美元。按我的经验,八成是蒸馏次数失控。加上分差门槛之后再看看,如果还高,就检查是不是每轮都在对同一批任务重复蒸馏。另一个常见浪费是给模型评判的提示词写得太长,把整个上下文原样贴进去,评判任务的输入量能占到总 token 的一半以上。评判时只给必要的片段,别偷懒。
故障五:跨任务迁移时全线崩盘。一换任务类型,之前积累的规则全部失效。这不一定是方法的问题,很可能是任务签名设计得太粗。检查方法是在库里按task_type分组统计,如果某一组里塞了几十条明显不属于同一类的规则,说明分类粒度不够,需要把类型标签拆细。
5.2 排查速查表
| 现象 | 最可能原因 | 优先检查项 | 处理动作 |
|---|---|---|---|
| 分数无区分度 | 硬指 |