搜索 Agent 的两条路——训练一个,还是工程搭一个

这篇博客把"搜索 Agent"拆成两个完整部分来讲:第一部分是训练视角 ,讲 Search-R1 怎样用强化学习让模型自己学会搜索;第二部分是工程视角,讲不训练模型、直接用 API 搭一个能跑的搜索 Agent。


目录

第一部分:训练视角(Search-R1)

  1. 问题定义:一条回答变成一条轨迹
  2. 数据:只给问题和答案
  3. 协议:模型动作只有三种状态
  4. 环境:搜索后端统一抽象
  5. rollout:多轮状态机
  6. token 连续性:多轮训练最容易踩的坑
  7. 奖励:只看最终结果
  8. GRPO:组内相对优势
  9. observation mask:搜索结果不参与预测
  10. Datum 构造与一次训练 step
  11. 评测:复用同一个 rollout
  12. 训练部分常见坑

第二部分:工程视角(API 搜索 Agent)

  1. 整体架构:一个工具调用循环

  2. 工具定义:让模型知道怎么搜

  3. 主循环:生成---执行---回填

  4. 多分支并行:让模型配合,代码执行

  5. 失败处理与上下文管理

  6. 可借鉴的开源项目

  7. 工程部分常见坑

第三部分:两条路的对照

  1. 训练 vs 工程:一张表看清

  2. 学习路线建议


第一部分:训练视角(Search-R1)

1. 问题定义:一条回答变成一条轨迹

大模型内部存了很多知识,但受训练数据时间、参数容量和长尾覆盖率限制,很多问题它答不准。固定 RAG 的做法是:系统先检索文档,再把结果交给模型回答。但搜不搜、搜什么、搜几次,全由外部流程决定,模型自己没得选。

Search-R1 换了个思路:把搜索决策交给模型。模型自己判断:

  • 现在需不需要搜索?
  • 搜什么词?
  • 看完结果后,要不要根据新证据继续搜?
  • 什么时候信息够了,可以给最终答案?

于是,一次回答不再是一个"输入→输出",而是一条多轮轨迹:

text 复制代码
τ = (x, a₁, o₁, a₂, o₂, ..., a_T)
  • x:用户问题
  • a_t:模型动作(搜索调用或最终回答)
  • o_t:环境返回的 observation(搜索结果)
  • T:轨迹结束

如果你刚接触强化学习,先记住四个词的对应关系:

RL 概念 在搜索 Agent 里对应什么
策略 policy 语言模型本身
动作 action 模型生成的搜索调用或最终答案
环境 environment 搜索后端(Wikipedia、DeepSeek Search 等)
奖励 reward 最终答案对不对、格式对不对
轨迹 trajectory 从问题开始到最终答案的一整串动作和观察

核心记住:搜索 Agent 训练的不是"回答模型",而是一个会自己决定"要不要搜、搜什么、搜几次、何时停"的多轮搜索策略。


2. 数据:只给问题和答案

训练样本只包含问题和参考答案:

json 复制代码
{"id":"...","question":"...","answers":["..."],"data_source":"nq"}

没有人工标注的搜索词,也没有标准搜索轨迹。

为什么?如果数据里给了"先搜什么、再搜什么",模型学的是模仿,而不是探索。Search-R1 要的是后者:让模型自己在搜索环境里试错。

数据清洗只保留可用于结果奖励的字段:问题非空、答案非空。prepare_data.py 负责统一 NQ 与 HotpotQA 的原始字段,data.py 把每一行转换为 SearchExample。

这个数据边界很重要:搜索 query、搜索次数和证据使用方式都必须由当前策略在 rollout 中自己探索。 如果训练集已经给出了固定检索轨迹,任务就更接近轨迹模仿,而不是 Search-R1 讨论的强化学习。


3. 协议:模型动作只有三种状态

每一轮 Assistant 输出会被严格解析为:

  1. 合法搜索:一轮中恰好一个完整工具调用,且调用后不能夹带最终答案;
  2. 最终回答:没有工具调用,作为轨迹结束;
  3. 非法输出:残缺标签、多个调用、调用后还有文本、query 为空或含非法字符等。

注意:没有单独的"停止"动作。 只要没有合法工具调用,本轮就作为最终回答结束;非法输出也结束轨迹,并得到格式惩罚。

两种协议可选:

协议 写法 优点 缺点
文本标签 <search>query</search> / <information>...</information> 不依赖 tokenizer 模板,任何模型都能用 需要严格解析,模型可能生成残缺标签
原生 function calling JSON schema + chat template 注入 结构化、和 Qwen 等模型原生兼容 依赖 tokenizer 模板,重渲染可能改变 token

解析器必须严格。以 parse_assistant() 为例,它要求:

  • 一轮中只能出现一次完整的 search 调用;
  • 工具调用后不能再夹带最终答案;
  • query 不能为空,也不能含 < >;
  • 包含残缺 <tool_call> 的输出会被标记为 invalid,同样结束轨迹并得到格式惩罚。

这样动作空间只有两类:搜索 或 回答。非法动作直接结束,并用负奖励告诉模型"这条路不对"。


4. 环境:搜索后端统一抽象

rollout 不直接依赖任何 HTTP 响应格式,只依赖统一接口:

python 复制代码
SearchItem(title, content, source, url)
SearchResult(ok, items, latency, status, error)

训练入口根据命令行参数创建后端,后面的状态机始终调用同一个 search(query) 接口。支持 DeepSeek Search、Wikipedia、知乎等。

以 Wikipedia 为例,一次请求同时完成全文搜索,并取回前三个页面的标题、正文摘要和 URL。外层 search() 还负责:

  • 请求限速;
  • timeout;
  • 429 与 5xx 的有限重试;
  • 累计成功率和 latency。

关键设计:搜索失败不抛异常。 重试后仍失败时返回 SearchResult(ok=False, error=...),rollout 把错误文本也作为 observation 交给模型。模型可以继续回答或改写 query。环境失败不能中断整条训练链。

不同后端的结果最终都被格式化为可读证据:

text 复制代码
[1] Title: ...
    Content: ...
    Source: ...
    URL: ...

只返回标题或链接通常不够,因为模型真正需要的是能够支持下一步推理的 content。max_tool_response_tokens 和 max_trajectory_tokens 会在 observation 接入轨迹前再次限制证据长度。


5. rollout:多轮状态机

5.1 轨迹保存什么

一条 Trajectory 保存:

  • 原始问题 example;
  • 组内编号 group_index;
  • 消息历史 messages;
  • 下一轮 prompt token next_prompt_tokens;
  • 每个 AssistantTurn:
    • 本轮采样前的完整 prompt token;
    • 模型生成的 completion token;
    • 对应旧策略 logprob;
    • 文本;
  • 搜索调用次数;
  • 最终文本;
  • reward、advantage;
  • 格式是否合法、是否 exact match、是否结束。

每个 AssistantTurn 记录的是模型真正生成的 token 和旧策略 logprob。搜索结果在 messages 和下一轮 prompt 中,但没有 rollout logprob,因为那些 token 来自环境,不是策略生成的。

5.2 状态机循环

text 复制代码
首轮:同一问题采样 G 个分支(共享 prompt)
  ↓
对每个分支:解析 Assistant 输出
  ├─ 合法搜索 → 并发执行搜索 → 回填 observation → 继续
  ├─ 最终回答 → 结束
  └─ 非法/预算耗尽 → 结束
  ↓
后续轮次:对每条未结束轨迹分别采样 1 个分支
  ↓
重复直到所有轨迹结束

一组 GRPO 轨迹在第一轮共享完全相同的 prompt。代码用一次请求生成 group_size 个分支,每个 sequence 都从根轨迹深拷贝出一个独立分支。

首轮之后,不同分支的 observation 和历史动作已经不同。后续轮次对每条未结束轨迹分别设置 num_samples=1,每条轨迹根据自己的搜索结果继续行动。

这同时满足两个条件:

  1. 同题轨迹能进行 GRPO 组内比较;
  2. 每条分支能根据自己的搜索结果继续行动。

5.3 五个预算参数,每条轨迹独立计算

参数 作用
max_search_calls 最多搜几次
max_assistant_turns 最多生成几轮 Assistant
max_assistant_tokens 单轮动作 token 上限
max_tool_response_tokens 单次 observation token 上限
max_trajectory_tokens 整条上下文总 token 上限

预算是分支级的,不是整组共享。 GRPO 组内比较要求同一道题的 G 条轨迹在相同规则下竞争。如果预算整组共享,先搜的分支会把预算用完,后面的分支就没法搜了,组内比较就不公平。

这三个 token 预算的关系是逐层嵌套的:

text 复制代码
整条轨迹总预算:max_trajectory_tokens
    ├── 初始 prompt(system + 工具定义 + 问题)
    ├── 第 1 轮 Assistant 动作:≤ max_assistant_tokens
    ├── 第 1 次 observation:≤ max_tool_response_tokens
    ├── 第 2 轮 Assistant 动作:≤ max_assistant_tokens
    ├── 第 2 次 observation:≤ max_tool_response_tokens
    └── ... 直到结束或总预算用完

单轮动作再长,也不能超过 max_assistant_tokens;单次搜索结果再丰富,也不能超过 max_tool_response_tokens;但即使每一轮都没超自己的局部上限,累计起来如果碰到 max_trajectory_tokens,轨迹也必须提前结束。

5.4 搜索执行与回填

resolve_searches() 用线程池并发调用不同轨迹的搜索后端,再按原顺序把结果和待处理轨迹配对。finish_search() 把结果写回轨迹:

  • fit_tool_content() 逐条加入搜索证据;
  • 只要 observation 超过单次工具预算或完整轨迹预算,就停止加入后续证据;
  • 如果连一条结果都放不下,轨迹直接结束;
  • 否则追加 tool message,设置下一轮 prompt token,搜索次数 +1。

这样,搜索服务返回多少文本 和模型实际能看到多少文本是两个明确分开的边界。


6. token 连续性:多轮训练最容易踩的坑

多轮训练最容易出现的问题:重新渲染 chat template 后的 token 和真实采样 token 不一致。

tokenizer 可能:

  • 清理空格;
  • 规范工具调用文本;
  • 自动补充轮次结束符。

如果训练阶段重新编码模型文本,旧 logprob 将无法和目标 token 一一对应。

本章遵循两个原则:

  1. Assistant 动作始终保留 sampler 返回的原始 token;
  2. 只通过 chat template 计算新加入的结束符与 tool observation token。

build_next_prompt() 的职责是:从 chat template 中只提取"Assistant 结束符 + 新 observation"这一段增量 token,再接到 sampler 返回的真实 completion 后面。它还会处理 overlap:如果 sampler 已经返回部分 Assistant 结束符,避免重复补 token。

messages 负责协议记录和调试,next_prompt_tokens 才是下一轮真正提交给 sampler 的模型输入,两者不能混为一谈。

构造训练数据时还会再次验证:

python 复制代码
if turn.prompt_tokens[:len(full_tokens)] != full_tokens:
    raise ValueError("下一轮 prompt 不是已有轨迹的前缀扩展")

这两层校验能尽早发现模板改写、结束符重复和 observation 拼接错误。

对 Agentic RL 来说,token 连续性属于算法正确性的一部分。 轨迹在文本层面看起来合理,仍可能因 token 边界错位而产生错误梯度。


7. 奖励:只看最终结果

短答案任务要求最终输出唯一一行:

text 复制代码
Answer: <short answer>

规则奖励分为三档:

text 复制代码
R(τ) = 1    格式正确且答案精确匹配
     = 0    格式正确但答案错误
     = -0.1 最终格式无效

reward.py 先抽取唯一一行最终答案,再统一大小写、标点、英文冠词和空格,然后和参考答案比较 exact match。

不为"搜了几次"或"query 看起来不错"加额外分。 这是 outcome reward:让模型自己探索出有效的搜索路径。

这段代码只评价最终任务结果。对同一道问题的 G 条完整轨迹,仍然使用 GRPO 组内基线。Search-R1 论文同时讨论了 PPO 与 GRPO 等优化方式,本章选择 GRPO 的同题分组方案,让 Search-R1 与前面的单轮 GRPO 使用同一种 advantage 构造方法。


8. GRPO:组内相对优势

对同一道问题的 G 条完整轨迹:

text 复制代码
A_i = R(τ_i) - (1/G) Σ R(τ_j)

通俗理解:

  • 同一道题生成多条轨迹;
  • 算这一组平均奖励;
  • 每条轨迹的 advantage = 自己奖励 - 组平均;
  • 比组平均好的动作链被鼓励,比组平均差的被抑制。

必须等整组轨迹全部结束后再算。 不能一条轨迹结束后立刻更新策略,因为同组其他分支的奖励还未知;也不能拆成 micro-batch 后再算,否则比较基线已经被改变。

整组 reward 完全相同时,所有 advantage 都为 0,这一组不会产生有效梯度。训练代码会跳过这些轨迹,并记录退化组比例。


9. observation mask:搜索结果不参与预测

这个 advantage 会分配给轨迹中每一轮 Assistant 生成的 token。搜索 observation 只构成下一步决策所需的状态,不是策略做出的动作,因此 observation token 的 logprob 和 advantage 都设为 0:

python 复制代码
full_tokens.extend(delta_observation)
old_logprobs_by_token.extend([0.0] * len(delta_observation))
advantages_by_token.extend([0.0] * len(delta_observation))

full_tokens.extend(turn.completion_tokens)
old_logprobs_by_token.extend(turn.logprobs)
advantages_by_token.extend([trajectory.advantage] * len(turn.completion_tokens))

这种 observation mask 有两个作用:

  1. 训练器不会要求模型预测搜索引擎返回的文本;
  2. observation 仍然保留在上下文中,后续 Assistant token 可以对这些证据进行条件建模。

搜索调用和最终回答共享一个轨迹级 advantage。若最终答案正确,导致成功的查询规划、搜索词和答案 token 都会得到鼓励;若最终答案错误,整条动作链都会被抑制。这正是 outcome reward 能够训练工具使用策略的原因。


10. Datum 构造与一次训练 step

10.1 build_datum

train.py 的 build_datum() 把 token 连续性和 observation mask 汇合到一起。核心步骤:

  1. 对每个 Assistant turn:
    • 第一轮的 delta_observation 是 system prompt、工具定义和用户问题;
    • 后续轮次的 delta_observation 是上一轮结束符、搜索结果和新一轮 Assistant 前缀;
    • 校验 turn.prompt_tokens 是已有 full_tokens 的前缀扩展;
  2. 拼接:
    • full_tokens;
    • old_logprobs_by_token:observation 为 0,Assistant token 用 rollout logprob;
    • advantages_by_token:observation 为 0,Assistant token 用轨迹 advantage;
  3. 检查 token、logprob、advantage 长度一致;
  4. 构造:
    • input_tokens = full_tokens[:-1]
    • target_tokens = full_tokens[1:]
    • old_logprobs = old_logprobs_by_token[1:]
    • advantages = advantages_by_token[1:]
  5. 检查不超过最大训练上下文;
  6. 包装成 trio.Datum。

只有 sampler 真正生成的 Assistant token 携带 rollout old logprob 和轨迹 advantage。其他 token 保留真实 target,但 old logprob 与 advantage 为 0。

10.2 一次训练 step 的顺序

text 复制代码
1. save_weights_and_get_sampling_client()  # 导出当前策略
2. rollout_batch()                         # 采样完整轨迹,算 reward 和 advantage
3. build_training_datums()                 # 跳过退化组,构造 Datum
4. pack_micro_batches()                    # 按长度拆批
5. 对每个 micro-batch: forward_backward()  # 累计梯度
6. optim_step()                            # 整个 logical batch 只更新一次

顺序不能交换:

  • save_weights_and_get_sampling_client() 在每个 step 开始时导出当前 LoRA 权重,因此 old logprob 来自本次更新前的当前策略;
  • rollout_batch() 内部已经完成整组 reward 和 advantage;
  • build_training_datums() 会跳过 advantage 全为 0 的轨迹;
  • 所有 micro-batch 只累计梯度,整个 logical batch 最后只调用一次 optim_step()。

10.3 micro-batch 加权

多轮轨迹长度差异很大,直接把所有数据放入一个 padded batch 会浪费大量 token。代码根据样本长度把 Datum 装入多个 micro-batch,并按每批轨迹数占完整 rollout 轨迹数的比例重新加权 loss。

PyTRIO 会对一次 forward_backward() 内的样本取平均。如果第 k 个 micro-batch 有 n_k 条轨迹,完整 rollout batch 有 N 条轨迹,代码会先把该批 advantage 乘以 n_k/N:

text 复制代码
Σ_k (n_k / N) · mean(L_k) = mean(L_global)

对应实现只重新包装 Datum,不修改 target token 和 old logprob。因此动态拆批只改变执行时的 padding 和显存占用,不改变逻辑 batch 的样本平均梯度。

importance_sampling 是 PPO/GRPO 类算法常用的损失形式:用旧策略 logprob 和新策略 logprob 的比值做重要性采样,再乘 advantage。对初学者只需记住:old logprob 必须来自生成轨迹时的策略,advantage 决定鼓励还是抑制这些 token。


11. 评测:复用同一个 rollout

评测没有另写一套简化的搜索逻辑,而是复用同一个 rollout_batch()。主要区别是每道题只保留一条轨迹,group_size=1,不再需要训练阶段的组内比较:

python 复制代码
config = RolloutConfig(
    group_size=1,
    max_search_calls=args.max_search_calls,
    max_assistant_turns=args.max_assistant_turns,
    max_trajectory_tokens=args.max_trajectory_tokens,
    max_assistant_tokens=args.max_assistant_tokens,
    max_tool_response_tokens=args.max_tool_response_tokens,
    search_concurrency=args.search_concurrency,
    temperature=args.temperature,
    top_p=args.top_p,
    seed=args.seed,
)

model_path 留空时评测 Base Model;传入 save_weights_for_sampler() 返回的路径时评测训练后 checkpoint。两种模型仍使用相同的协议、搜索后端、预算、题集与答案判定器。

必须记录的实验指标

  • 最终答案 exact match;
  • 有效答案格式比例;
  • 平均搜索调用次数与零搜索比例;
  • 搜索失败率、超时率和 observation 截断率;
  • 平均轨迹长度与每步有效训练 token;
  • 退化组比例;
  • rollout、搜索和训练各阶段耗时。

可复现性边界

搜索结果具有外部依赖。在线页面会更新,搜索服务的排序也可能变化。比较 Base Model 与训练 checkpoint 时,应固定题集、搜索后端、搜索预算、sampling 参数和评测时间窗口。若需要严格可复现的论文实验,可以将检索语料与索引固定为只读快照。


12. 训练部分常见坑

  1. 重新编码 Assistant 文本:导致 old logprob 和 target token 错位。必须保留 sampler 原始 token。
  2. 组内均值拆批计算:advantage 基线被改变。必须整组结束后再算。
  3. observation 没 mask:训练器会要求模型预测搜索引擎文本,梯度被污染。
  4. 搜索失败抛异常 :整条训练链中断。应返回 ok=False,把错误文本交给模型。
  5. 预算整组共享:先搜的分支用完预算,组内比较不公平。预算必须按分支独立。
  6. 评测配置和训练不一致:更高搜索预算本身就能提高正确率,对比不公平。
  7. 没有退化组处理:整组 reward 相同,advantage 全 0,产生无效梯度。应跳过并记录比例。
  8. micro-batch 直接平均 :不等大的 batch 平均会改变全局样本平均。必须乘 n_k/N 加权。
  9. 用 messages 重新编码替代 prompt_tokens:messages 是结构化历史记录,prompt_tokens 是真实采样 token 序列,重新渲染不保证逐 token 一致。

第二部分:工程视角(API 搜索 Agent)

13. 整体架构:一个工具调用循环

如果你不想训练模型,而是直接用现成的大模型 API(OpenAI、DeepSeek、Kimi 等),那上面 GRPO、observation mask、token 连续性全部不需要。你只需要实现一个工具调用循环。

text 复制代码
你的程序
  ├── 1. 定义搜索工具(JSON schema)
  ├── 2. 调 LLM API(带上工具定义 + 对话历史)
  ├── 3. 检查返回:有没有 tool_calls?
  │      ├── 有 → 执行搜索 → 把结果拼回 messages → 回到第 2 步
  │      └── 没有 → 输出最终答案,结束
  └── 4. 设置最大循环次数(防止无限搜)

这个循环就是所有搜索 Agent 的核心骨架。你不需要训练模型,只需要一个支持 function calling 的 API,加一个搜索后端。


14. 工具定义:让模型知道怎么搜

用 OpenAI 兼容的 function calling 格式:

python 复制代码
search_tool = {
    "type": "function",
    "function": {
        "name": "search",
        "description": "搜索互联网获取信息。当你不确定答案、需要最新信息、或需要验证事实时使用。",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "搜索关键词"}
            },
            "required": ["query"],
            "additionalProperties": False
        }
    }
}

建议 additionalProperties: false 并明确 required,减少模型生成无效参数的概率。

system prompt 里要写清楚三件事:

  1. 什么时候搜:不确定、需要最新信息、需要验证事实时才搜;
  2. 搜索词怎么写:简洁、具体,避免长句;
  3. 最终答案格式 :比如 Answer: xxx,方便程序后续解析。

示例:

text 复制代码
你是一个搜索助手。需要外部信息时调用 search 工具。
单个搜索词可能遗漏相关信息,当问题涉及多个方面时,可以同时发起多个不同角度的查询。
最终用 Answer: <short answer> 格式回答,不要输出多余内容。

15. 主循环:生成---执行---回填

python 复制代码
from openai import OpenAI
import json

client = OpenAI(api_key="...", base_url="...")

def do_search(query: str) -> str:
    try:
        results = tavily_client.search(query, max_results=3)
        return "\n\n".join(
            f"{r['title']}\n{r['content']}" for r in results
        )[:2000]
    except Exception as e:
        return f"搜索失败:{e},请尝试改写查询。"

messages = [
    {"role": "system", "content": SYSTEM_PROMPT},
    {"role": "user", "content": user_question}
]

for round in range(MAX_ROUNDS):  # 比如 3~5
    response = client.chat.completions.create(
        model="your-model",
        messages=messages,
        tools=[search_tool],
    )
    msg = response.choices[0].message

    if msg.tool_calls:
        messages.append(msg)  # 必须追加完整 assistant message
        for tc in msg.tool_calls:
            query = json.loads(tc.function.arguments)["query"]
            result = do_search(query)
            messages.append({
                "role": "tool",
                "tool_call_id": tc.id,
                "content": result
            })
    else:
        print(msg.content)
        break

关键细节:必须把 SDK 返回的完整 assistant message 追加到 messages ,不能只复制 content 和 tool_calls,否则可能丢失推理内容,破坏后续工具调用上下文。


16. 多分支并行:让模型配合,代码执行

"有的查询搜不到,所以需要并行搜索"是工程上很常见的需求。核心思路是:把"生成多个查询词"的责任交给模型,把"并行执行"的控制权留在代码里。

16.1 方法一:单轮多 tool_call

主流 API 支持 parallel tool calling。在 system prompt 里明确引导:

"单个搜索词可能遗漏相关信息。当问题涉及多个方面、或你不确定哪个查询词能命中时,请同时生成多个不同角度的查询。"

代码端用 asyncio.gather 并发执行:

python 复制代码
if response.choices[0].message.tool_calls:
    results = await asyncio.gather(*[
        do_search(json.loads(tc.function.arguments)["query"])
        for tc in response.choices[0].message.tool_calls
    ])
    for tc, result in zip(response.choices[0].message.tool_calls, results):
        messages.append({
            "role": "tool",
            "tool_call_id": tc.id,
            "content": result
        })

部分推理模型(如 o3/o4-mini)倾向于每轮只输出一个 tool_call,这种情况下 gpt-4o 或类似模型更适合多查询场景。你可以在调用前确认模型是否支持单轮多 tool_call。

16.2 方法二:工具参数改成数组

如果模型不太配合单轮多 tool_call,可以把工具参数改成数组:

python 复制代码
search_tool = {
    "type": "function",
    "function": {
        "name": "batch_search",
        "description": "并行搜索多个查询词。当一次搜索可能不够时,一次给出 2-4 个不同角度的查询。",
        "parameters": {
            "type": "object",
            "properties": {
                "queries": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "2-4 个不同角度的搜索词"
                }
            },
            "required": ["queries"]
        }
    }
}

模型只调用一次,参数里带列表。代码拿到列表后并发搜索、合并去重,作为一个 tool 结果返回。

python 复制代码
async def do_searches(queries: list[str]) -> str:
    tasks = [search_one(q) for q in queries]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    merged = []
    for q, r in zip(queries, results):
        if isinstance(r, Exception):
            merged.append(f"[{q}] 搜索失败")
        else:
            merged.append(f"[{q}]\n{r}")
    return "\n\n".join(merged)

16.3 方法三:协调 Agent + 并行 Worker

如果想要真正的"多个独立分支各自探索、最后汇总",用两层结构:

python 复制代码
async def run_worker(task: str) -> str:
    """一个独立的 mini agent,只处理一个子任务"""
    messages = [
        {"role": "system", "content": "你是一个搜索助手,只负责回答分配给你的子问题。"},
        {"role": "user", "content": task}
    ]
    for _ in range(3):
        resp = await client.chat.completions.create(
            model=..., messages=messages, tools=[search_tool]
        )
        msg = resp.choices[0].message
        if msg.tool_calls:
            messages.append(msg)
            for tc in msg.tool_calls:
                query = json.loads(tc.function.arguments)["query"]
                result = await search_one(query)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": result
                })
        else:
            return msg.content
    return messages[-1].get("content", "")

async def main(question: str):
    # 1. 协调者拆分任务
    resp = await client.chat.completions.create(
        model=...,
        messages=[{"role": "user", "content": f"把这个问题拆成 3 个需要并行搜索的子问题,用 JSON 数组返回:{question}"}],
    )
    subtasks = json.loads(resp.choices[0].message.content)

    # 2. 并行跑多个 worker
    results = await asyncio.gather(*[run_worker(t) for t in subtasks])

    # 3. 汇总
    final = await client.chat.completions.create(
        model=...,
        messages=[{"role": "user", "content": f"根据以下搜索结果回答问题:{question}\n\n" + "\n\n".join(results)}],
    )
    return final.choices[0].message.content

这个模式在 agentic patterns 里叫 Parallel Worker Agent Spawning:协调 agent 创建多个 worker,各自搜索不同领域/角度,最后聚合。

16.4 工程上要处理的事

  1. 并发数限制:设上限(比如 5 个),避免触发搜索 API 速率限制;
  2. 失败隔离 :用 asyncio.gather(..., return_exceptions=True),某个搜索失败不影响其他;
  3. 结果合并去重:按 URL 或标题去重,减少 token 浪费;
  4. tool_call_id 配对 :每个 tool 结果必须带对应 tool_call_id,否则模型无法把结果和查询对应起来。

17. 失败处理与上下文管理

17.1 搜索失败

不要让一次搜索异常中断整个流程。do_search 内部 try/except,失败时返回错误文本,让模型看到"这个角度没搜到",而不是整个流程崩溃。

python 复制代码
def do_search(query: str) -> str:
    try:
        ...
    except Exception as e:
        return f"搜索失败:{e},请尝试改写查询。"

17.2 上下文长度

每轮搜索结果都会拼进 messages,上下文会越来越长。需要:

  • 单次结果截断 :比如每次返回前 [:2000] 字符;
  • 总轮次限制 :MAX_ROUNDS = 3~5;
  • 必要时压缩历史:只保留最近几轮的 observation,或者让模型自己总结已有证据。

17.3 成本控制

控制项 做法 原因
最大搜索次数 MAX_ROUNDS = 3~5 防止模型无限循环
搜索结果截断 每次返回前 [:2000] 字符 控制上下文长度和 API 成本
搜索失败处理 try/except 返回错误文本 不让一次搜索异常中断整个流程
最终答案格式 system prompt 里要求 Answer: xxx 方便程序后续解析
并发上限 比如 5 个 避免触发搜索 API 速率限制

18. 可以借鉴的开源项目

都是基于 API、不涉及模型训练的工程实现:

项目 特点 适合借鉴什么
MindSearch 模仿人类思维做深度搜索,支持 simultaneous multi-query search 多查询并发、结果汇总的 agent 设计
II-Researcher 用 BAML 函数编排搜索和推理,支持 Tavily 和 SerpAPI pipeline 结构、多步搜索编排
Open Research Agent supervisor 规划 + researcher 搜索 + writer 写报告,默认 DeepSeek API 规划-执行-审查完整链路
SGR Agent Core 多种 agent 实现,暴露 OpenAI 兼容 REST API 把搜索 Agent 封装成独立服务
sac-agent 让 LLM 生成 Python 代码执行搜索,内置 search_fanout 并发函数 把并行搜索逻辑代码化、沙箱执行

如果想直接跑一个能用的,Open Research Agent 的 CLI 上手最快;如果想借鉴多查询并发设计,先看 MindSearch 的 agent 模块;如果想把搜索 Agent 封装成 API 服务,看 SGR Agent Core 的 Docker 部署和 REST 接口设计。


19. 工程部分常见坑

  1. 只追加 content 不追加完整 assistant message:工程上会破坏 tool_call 上下文。
  2. tool 结果不带 tool_call_id:模型无法把结果和查询对应起来。
  3. 搜索 API 不限并发:容易触发速率限制,导致大量失败。
  4. 搜索结果不截断:上下文迅速膨胀,成本和延迟失控。
  5. 没有最大轮次限制:模型可能无限搜索或反复输出非搜索动作。
  6. 搜索失败直接抛异常:整个流程崩溃。应返回错误文本,让模型决定下一步。
  7. 多查询结果不去重:重复内容浪费 token,也干扰模型判断。
  8. system prompt 没写最终答案格式:程序无法稳定解析结果。

第三部分:两条路的对照

20. 训练 vs 工程:一张表看清

维度 训练视角(Search-R1) 工程视角(API Agent)
模型 自己训练,更新策略 调现成 API,不训练
搜索决策 模型自己探索 system prompt 引导
数据 只给问题和答案 不需要训练数据
奖励 结果奖励 + 格式惩罚 不需要
多分支 组内 G 条轨迹用于比较 多查询并发或 worker 并行
预算 每条轨迹独立计算 控制 MAX_ROUNDS 和并发数
协议 严格解析,非法输出结束并惩罚 function calling 或标签解析
环境 统一抽象,失败不抛异常 搜索函数 try/except
最容易踩的坑 token 连续性、observation mask tool_call_id 配对、结果去重
适用场景 有训练资源,想提升模型搜索能力 快速落地,用现成模型

两条路共享同一个核心思想:

搜索 Agent 的核心不是"让它搜",而是设计一套让模型自己判断"要不要搜、搜什么、搜几次、何时停"的决策逻辑。

区别只在于:训练视角让模型学会 这个决策;工程视角用 prompt 和代码引导这个决策。

相关推荐
在所不辞兄1 小时前
【零基础学智能仿真-58】Foam-Agent协作与知识检索
人工智能·深度学习·神经网络·机器学习·有限元·仿真计算
cc_瀚海知行1 小时前
工具调用 6|助手什么时候该停下来?
java·服务器·前端·人工智能·python·ai
Martina_03211 小时前
AI生成3D模型UV岛太碎,重绘贴图全是接缝?用6步重整UV并保留原材质
图像处理·人工智能·3d·自然语言处理·贴图·uv·游戏策划
youdexiang1 小时前
Android录音APP微信语音导入机制详解
人工智能·语音识别
悟天特斯1 小时前
楼宇自控的“协同优化“:从设备控制到系统级节能与舒适平衡
人工智能·物联网
叫我啊腻1 小时前
Nano Banana 2.1 深度测评:升级了哪些,和 GPT Image 2.5 怎么选?
人工智能
johnsong1 小时前
AI前沿日报 2026-10-21:开源AI全面反击
人工智能·开源
漫步人生走在路上1 小时前
外贸SEO中的品牌词与产品词策略:大卖家和小卖家的不同打法
大数据·javascript·网络·数据库·人工智能·搜索引擎
2503_931712481 小时前
数据服务公司怎么帮助企业更高效找到行业数据与商业情报
大数据·人工智能·microsoft