这篇博客把"搜索 Agent"拆成两个完整部分来讲:第一部分是训练视角 ,讲 Search-R1 怎样用强化学习让模型自己学会搜索;第二部分是工程视角,讲不训练模型、直接用 API 搭一个能跑的搜索 Agent。
目录
第一部分:训练视角(Search-R1)
- 问题定义:一条回答变成一条轨迹
- 数据:只给问题和答案
- 协议:模型动作只有三种状态
- 环境:搜索后端统一抽象
- rollout:多轮状态机
- token 连续性:多轮训练最容易踩的坑
- 奖励:只看最终结果
- GRPO:组内相对优势
- observation mask:搜索结果不参与预测
- Datum 构造与一次训练 step
- 评测:复用同一个 rollout
- 训练部分常见坑
第二部分:工程视角(API 搜索 Agent)
-
整体架构:一个工具调用循环
-
工具定义:让模型知道怎么搜
-
主循环:生成---执行---回填
-
多分支并行:让模型配合,代码执行
-
失败处理与上下文管理
-
可借鉴的开源项目
-
工程部分常见坑
第三部分:两条路的对照
-
训练 vs 工程:一张表看清
-
学习路线建议
第一部分:训练视角(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 输出会被严格解析为:
- 合法搜索:一轮中恰好一个完整工具调用,且调用后不能夹带最终答案;
- 最终回答:没有工具调用,作为轨迹结束;
- 非法输出:残缺标签、多个调用、调用后还有文本、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,每条轨迹根据自己的搜索结果继续行动。
这同时满足两个条件:
- 同题轨迹能进行 GRPO 组内比较;
- 每条分支能根据自己的搜索结果继续行动。
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 一一对应。
本章遵循两个原则:
- Assistant 动作始终保留 sampler 返回的原始 token;
- 只通过 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 有两个作用:
- 训练器不会要求模型预测搜索引擎返回的文本;
- observation 仍然保留在上下文中,后续 Assistant token 可以对这些证据进行条件建模。
搜索调用和最终回答共享一个轨迹级 advantage。若最终答案正确,导致成功的查询规划、搜索词和答案 token 都会得到鼓励;若最终答案错误,整条动作链都会被抑制。这正是 outcome reward 能够训练工具使用策略的原因。
10. Datum 构造与一次训练 step
10.1 build_datum
train.py 的 build_datum() 把 token 连续性和 observation mask 汇合到一起。核心步骤:
- 对每个 Assistant turn:
- 第一轮的
delta_observation是 system prompt、工具定义和用户问题; - 后续轮次的
delta_observation是上一轮结束符、搜索结果和新一轮 Assistant 前缀; - 校验
turn.prompt_tokens是已有full_tokens的前缀扩展;
- 第一轮的
- 拼接:
full_tokens;old_logprobs_by_token:observation 为 0,Assistant token 用 rollout logprob;advantages_by_token:observation 为 0,Assistant token 用轨迹 advantage;
- 检查 token、logprob、advantage 长度一致;
- 构造:
input_tokens = full_tokens[:-1]target_tokens = full_tokens[1:]old_logprobs = old_logprobs_by_token[1:]advantages = advantages_by_token[1:]
- 检查不超过最大训练上下文;
- 包装成
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. 训练部分常见坑
- 重新编码 Assistant 文本:导致 old logprob 和 target token 错位。必须保留 sampler 原始 token。
- 组内均值拆批计算:advantage 基线被改变。必须整组结束后再算。
- observation 没 mask:训练器会要求模型预测搜索引擎文本,梯度被污染。
- 搜索失败抛异常 :整条训练链中断。应返回
ok=False,把错误文本交给模型。 - 预算整组共享:先搜的分支用完预算,组内比较不公平。预算必须按分支独立。
- 评测配置和训练不一致:更高搜索预算本身就能提高正确率,对比不公平。
- 没有退化组处理:整组 reward 相同,advantage 全 0,产生无效梯度。应跳过并记录比例。
- micro-batch 直接平均 :不等大的 batch 平均会改变全局样本平均。必须乘
n_k/N加权。 - 用 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 里要写清楚三件事:
- 什么时候搜:不确定、需要最新信息、需要验证事实时才搜;
- 搜索词怎么写:简洁、具体,避免长句;
- 最终答案格式 :比如
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 工程上要处理的事
- 并发数限制:设上限(比如 5 个),避免触发搜索 API 速率限制;
- 失败隔离 :用
asyncio.gather(..., return_exceptions=True),某个搜索失败不影响其他; - 结果合并去重:按 URL 或标题去重,减少 token 浪费;
- 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. 工程部分常见坑
- 只追加 content 不追加完整 assistant message:工程上会破坏 tool_call 上下文。
- tool 结果不带 tool_call_id:模型无法把结果和查询对应起来。
- 搜索 API 不限并发:容易触发速率限制,导致大量失败。
- 搜索结果不截断:上下文迅速膨胀,成本和延迟失控。
- 没有最大轮次限制:模型可能无限搜索或反复输出非搜索动作。
- 搜索失败直接抛异常:整个流程崩溃。应返回错误文本,让模型决定下一步。
- 多查询结果不去重:重复内容浪费 token,也干扰模型判断。
- 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 和代码引导这个决策。