跳转至

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

参考资料