LATS:在候选方案之间搜索
有些任务不应该停在第一个答案。
旅游助手可能给出好几种行程方案:先去西湖的、先去茶博物馆的、步行最少的。要选好,需要候选、评分、剪枝。
LATS 把推理当搜索:展开候选 → 评分 → 保留 top-K → 继续搜索。
一句话说清楚
LATS 把"只生成一个答案"改成"展开候选 → 评分 → 保留最优 → 搜索",用更多算力换更好的候选筛选。
解决什么问题
| 问题 | 表面看起来 | 实际风险 |
|---|---|---|
| 只有一个候选 | 快 | 卡在一个弱方案上 |
| 有候选但不评分 | 选择多 | 无法选择 |
| 搜索没有预算 | 看起来聪明 | token 和延迟爆炸 |
旅游助手一次给一个行程,用户说"不好"就从头来。LATS 先生成多个方案,评分后保留最好的,比"生成-否决-重来"高效得多。
引入什么复杂度
- 需要 Proposer 和 Evaluator 两个模型角色。
- 搜索空间控制不好就爆炸:
branch_factor × depth个候选。 - Evaluator 本身可能不靠谱——打高分的方案不一定真的好。
- 这是本章最重的模式。
和其他模式的关系
| 模式 | 谁决定下一步 | 什么时候用 |
|---|---|---|
| Voting | 独立回答后投票 | 短答案、可归一化 |
| Plan & Solve | 一个计划执行 | 计划质量稳定 |
| LATS | 搜索控制器保留候选 | 多条路径都有可能 |
| Self-Discovery | 先选策略模块 | 问题在于选错策略 |
LATS 比 Voting 更有结构(不只是投票,而是迭代改进),比 Plan & Solve 更重(多个候选 × 多轮)。
这个模式改变了什么
| 谁 | 负责什么 |
|---|---|
| Proposer(模型) | 给定 parent 生成多个候选 |
| Evaluator(模型) | 给每个候选打分(0-10) |
| 搜索控制器(Python) | 控制 depth、branch_factor、beam_size |
| Python | 存候选树和 trace |
- 谁决定下一步:Python 搜索控制器。每轮展开、评分、剪枝。
- 谁拥有状态:Python 持有候选树(beam)。
- 什么时候停止:达到最大 depth 或搜索预算。
- trace 记录什么:
lats.expand(每轮展开数)、lats.score(每个候选的分数)。
走一遍真实轨迹
用户请求:"设计一个杭州两天行程,步行少、有文化体验。"
| 阶段 | 输出 | 分数 |
|---|---|---|
| 展开 depth=0 | 方案 A:"西湖游船 + 灵隐寺" | 6 |
| 方案 B:"茶博物馆 + 宋城演出" | 8 | |
| 保留 | 方案 B(top-1) | |
| 展开 depth=1(基于 B) | 方案 B1:"茶博物馆 + 宋城 + 河坊街晚餐" | 7 |
| 方案 B2:"茶博物馆 + 宋城 + 西湖夜游船" | 9 | |
| 保留 | 方案 B2(最终最优) |
方案 A 步行多(西湖环湖),被淘汰。方案 B 的基础上继续改进,最终 B2 得分最高。
流程图
flowchart TD
T["用户任务"] --> E["展开候选"]
E --> S["评分"]
S --> K["保留 top-K"]
K --> D{"depth/预算到了?"}
D -->|没到| E
D -->|到了| O["返回最优候选"]
完整实现
from __future__ import annotations
from pathlib import Path
from agent_patterns_lab.patterns.lats import lats_beam_search
from agent_patterns_lab.runtime import MockLLM, Tracer
def main() -> None:
tracer = Tracer()
# Proposer:每轮生成候选方案
proposer = MockLLM(
[
'{"candidates":["西湖游船+灵隐寺(步行较多)","茶博物馆+宋城演出(室内为主)"]}',
'{"candidates":["茶博物馆+宋城+河坊街晚餐","茶博物馆+宋城+西湖夜游船"]}',
]
)
# Evaluator:给每个候选打分
evaluator = MockLLM(
[
'{"score": 6}', # 方案 A:步行多,扣分
'{"score": 8}', # 方案 B:室内为主,好
'{"score": 7}', # B1:河坊街还行
'{"score": 9}', # B2:夜游船有文化感
]
)
result = lats_beam_search(
proposer,
evaluator,
task="设计杭州两天行程,步行少、有文化体验。",
depth=2,
branch_factor=2,
beam_size=1,
tracer=tracer,
)
print(f"最优方案:{result.best}")
print(f"得分:{result.score}")
trace_path = tracer.export_jsonl(Path(".traces") / "54_lats_zh.jsonl")
print(f"\n[trace] {trace_path}")
if __name__ == "__main__":
main()
运行:
PYTHONPATH=src python examples/54_lats.py
完整运行记录
最优方案:茶博物馆+宋城+西湖夜游船
得分:9.0
[trace] .traces/54_lats_zh.jsonl
trace 事件:
lats.expand depth=0 parent_len=0 candidates=2
lats.score depth=0 score=6
lats.score depth=0 score=8
lats.expand depth=1 parent_len=18 candidates=2
lats.score depth=1 score=7
lats.score depth=1 score=9
可以清楚看到:depth=0 淘汰了方案 A,depth=1 在方案 B 的基础上继续优化。
踩坑与诊断
| 坑 | 现象 | trace 信号 | 修法 |
|---|---|---|---|
| Evaluator 不靠谱 | 烂方案拿高分 | 最终方案和约束明显冲突 | 用规则、多个 judge、或人工抽检 |
| 搜索爆炸 | 候选节点过多 | expand 事件数远超预期 | 限制 depth、branch_factor、beam_size |
| 候选太相似 | 所有方案都差不多 | 候选文本相似度高 | 在 Proposer prompt 里要求多样性 |
| 奖励 hack | 候选讨好评分器 | 高分方案读起来像广告 | 用规则检查、人工抽检 |
关键诊断技巧:trace 里每个 lats.score 事件都记录了分数。如果所有分数都在 7-8 之间(方差极小),说明 Evaluator 缺乏区分度,搜索实际上没有筛选效果。
工程备忘
成本公式:
模型调用 = depth × beam_size × (1 + branch_factor)
= depth × beam_size × branch_factor(展开)+ depth × beam_size × branch_factor(评分)
简化:depth=2, beam=1, branch=2 → 2×1×2 + 2×1×2 = 8 次调用
depth=3, beam=2, branch=3 → 3×2×3 + 3×2×3 = 36 次调用
这是本章最贵的模式。token 消耗和 depth × beam × branch 成正比。
生产注意事项:
- 生产中通常 depth=1-2, beam_size=2-3, branch_factor=2-3。参数乘起来超过 20 次调用时要警惕。
- Evaluator 质量是整个模式的瓶颈。如果评分不可靠,LATS 不会比随机选择好多少。
- LATS 适合"答案值得花钱"的场景:旅游路线、合同审查、投资建议。不适合 FAQ 回答。
- 可以用小模型做 Proposer、大模型做 Evaluator,或者用规则替代 Evaluator。
读完以后
LATS 是最重的规划模式,适合"多条路都有可能、且值得搜索"的场景。
- 如果只需要多份答案投票,用更简单的 Voting。
- 如果问题在于选错了推理策略,读 Self-Discovery。
- 如果一个计划就够用,退回 Plan & Solve。