让 Agent 调用一次搜索接口并不难:生成查询词、发送请求、读取结果即可。真正棘手的是,当召回内容不相关、答案缺少证据或搜索成本过高时,Agent 能否判断问题出在哪里,提出改动,并通过多轮实验验证搜索是否真的变好了。
火山引擎开源 Agent 驱动的搜索自迭代技术,值得关注的核心正是这种能力边界的推进:搜索不再只是 Agent 的一个工具,而是一个可以被评估、调整和持续优化的子系统。由于来源摘要没有展开具体 API 和内部实现,下面不假设某个专有接口,而是从工程角度拆解一套可以实践的最小闭环。
一次搜索调用,与搜索自迭代的差别
普通搜索 Agent 往往是一条线性链路:
用户问题 -> LLM 生成查询 -> 搜索服务 -> LLM 汇总答案
搜索自迭代则需要在这条链路之外增加反馈回路:
任务集
-> 当前搜索策略
-> 执行搜索
-> 评估结果
-> 定位失败类型
-> 生成候选策略
-> 对照实验
-> 保留更优版本
这里的“策略”不应只理解为 Prompt。它至少可能包含:
- 查询改写:删除停用词、补充同义词、拆分复杂问题;
- 搜索计划:先宽召回再精排,或者按时间、站点和文档类型分步搜索;
- 工具参数:
top_k、过滤条件、超时时间、重试次数; - 结果处理:去重、重排、证据片段抽取;
- 停止条件:何时继续搜索,何时认为证据已经足够。
因此,自迭代的对象最好是一个结构化配置,而不是一段完全自由生成的文本。结构化配置更容易比较、回滚,也更适合进入持续集成流程。
评估器决定 Agent 会朝哪个方向优化
如果评价标准只是“最终回答看起来不错”,Agent 很容易学会写得更像正确答案,却没有改善搜索。一个可用的评估体系通常需要分层。
检索层指标关注目标证据有没有被找到:
- Recall@K:正确文档是否出现在前 K 条结果中;
- MRR:第一条正确结果出现得是否足够靠前;
- 去重率与来源覆盖率;
- 单次任务的查询次数、延迟和费用。
回答层指标关注搜索结果是否被正确使用:
- 结论是否能由检索证据支持;
- 引用是否与具体论断匹配;
- 信息不足时是否明确表达不确定性;
- 是否混入搜索结果之外的未经验证内容。
线上系统还应增加业务指标,例如问题解决率、用户追问率和人工接管率。优化目标可以写成一个带约束的分数:
总分 = 0.5 × 证据召回 + 0.3 × 答案可信度 + 0.2 × 来源覆盖
- 延迟惩罚 - Token 成本惩罚 - 无效搜索惩罚
权重不是固定答案。面向故障排查的 Agent 可能更看重召回率,面向实时客服的 Agent 则必须同时约束延迟。关键是把目标写清楚,否则迭代过程只会把偶然波动当作改进。
一个可运行的最小自迭代实验
下面的示例不依赖外部服务,用本地文档模拟搜索引擎。它把查询扩展、停用词处理和 top_k 作为可变策略,在训练任务上选择候选配置,再用留出任务检查是否过拟合。
将以下内容保存为 self_iter_search.py,使用 Python 3.10 及以上版本运行。生产环境中可以把 search() 替换成真实搜索 API,把 mutate() 替换成 LLM 生成候选策略的步骤。
from __future__ import annotations
import re
from dataclasses import dataclass, replace
DOCS = [
('k8s', 'Diagnose a pod that keeps restarting with crash loop logs'),
('auth', 'Rotate an API credential safely without service downtime'),
('db', 'Speed up slow SQL queries by adding and validating indexes'),
('cache', 'Use a cache to reduce repeated network requests'),
]
TRAIN_TASKS = [
('pod keeps restarting', 'k8s'),
('renew service token', 'auth'),
]
HOLDOUT_TASKS = [
('reduce database query latency', 'db'),
]
STOP_WORDS = {'a', 'an', 'the', 'how', 'to', 'can', 'i'}
SYNONYMS = {
'renew': ['rotate'],
'service': ['api'],
'token': ['credential'],
'reduce': ['speed'],
'database': ['sql'],
'latency': ['slow'],
}
def tokens(text: str) -> list[str]:
return re.findall(r'[a-z0-9]+', text.lower())
@dataclass(frozen=True)
class Strategy:
drop_stop_words: bool = False
expand_synonyms: bool = False
top_k: int = 1
def rewrite(query: str, strategy: Strategy) -> list[str]:
result = tokens(query)
if strategy.drop_stop_words:
result = [word for word in result if word not in STOP_WORDS]
if strategy.expand_synonyms:
expanded = list(result)
for word in result:
expanded.extend(SYNONYMS.get(word, []))
result = expanded
return result
def search(query: str, strategy: Strategy) -> list[str]:
query_words = set(rewrite(query, strategy))
ranked = []
for doc_id, text in DOCS:
doc_words = set(tokens(text))
overlap = len(query_words & doc_words)
ranked.append((overlap, doc_id))
ranked.sort(key=lambda item: (-item[0], item[1]))
return [doc_id for _, doc_id in ranked[:strategy.top_k]]
def evaluate(tasks, strategy: Strategy):
failures = []
hits = 0
for query, expected_doc in tasks:
results = search(query, strategy)
if expected_doc in results:
hits += 1
else:
failures.append({
'query': query,
'expected': expected_doc,
'actual': results,
})
return hits / len(tasks), failures
def mutate(strategy: Strategy) -> set[Strategy]:
candidates = {
strategy,
replace(strategy, drop_stop_words=not strategy.drop_stop_words),
replace(strategy, expand_synonyms=not strategy.expand_synonyms),
replace(strategy, top_k=min(3, strategy.top_k + 1)),
}
return candidates
def complexity_penalty(strategy: Strategy) -> tuple[int, int, int]:
# 分数相同时,优先选择更少结果、更少改写的简单策略。
return (
strategy.top_k,
int(strategy.expand_synonyms),
int(strategy.drop_stop_words),
)
def main() -> None:
current = Strategy()
for round_no in range(1, 4):
candidates = mutate(current)
scored = []
for candidate in candidates:
score, failures = evaluate(TRAIN_TASKS, candidate)
scored.append((score, candidate, failures))
scored.sort(
key=lambda item: (-item[0], complexity_penalty(item[1]))
)
best_score, current, failures = scored[0]
print(f'round={round_no} train_score={best_score:.2f}')
print(f'strategy={current}')
print(f'failures={failures}\n')
holdout_score, holdout_failures = evaluate(HOLDOUT_TASKS, current)
print(f'holdout_score={holdout_score:.2f}')
print(f'holdout_failures={holdout_failures}')
if __name__ == '__main__':
main()
运行命令:
python self_iter_search.py
这个示例刻意保持简单,但已经包含自迭代系统的几个关键部件:可修改的策略、固定任务集、自动评估、候选实验、复杂度约束和留出集验证。接入 LLM 后,可以让模型读取失败样本并输出结构化修改建议,例如:
{
"failure_type": "vocabulary_mismatch",
"changes": {
"expand_synonyms": true,
"top_k": 2
},
"reason": "The query uses token while the target document uses credential."
}
不要让模型直接修改线上策略。更稳妥的做法是:模型只生成候选配置,实验执行器负责校验字段、限制参数范围,并在离线数据或影子流量上比较新旧版本。
从演示走向生产,需要补上的护栏
搜索自迭代并不等于无限循环。没有边界的优化过程可能产生四类问题。
评估器投机。 Agent 可能迎合评分器,而不是改善真实搜索。应混合使用确定性指标、人工标注和盲测,并定期检查高分失败案例。
训练集过拟合。 某个改写规则可能只对少量问题有效。任务集应按时间、领域和难度分层,保留从未参与策略选择的测试集。
成本失控。 提高 top_k、增加查询分支通常能换来更高召回,但也会增加搜索费用和延迟。每轮实验都应记录查询数、Token、耗时和外部 API 成本。
策略污染与安全风险。 搜索页面可能包含提示注入内容。系统应把网页文本视为不可信数据,禁止搜索结果直接改变系统指令、凭据权限或发布流程。
一套务实的上线检查表可以是:
- 策略配置是否版本化,并能一键回滚;
- 每次改动是否记录任务集、指标、成本和失败样本;
- 是否同时使用训练集、验证集和长期保留集;
- 候选策略是否经过参数校验和权限限制;
- 新版本是否先运行离线评测,再进入影子流量或小比例灰度;
- 指标下降、成本突增或异常查询出现时,是否自动停止迭代。
搜索 Agent 的下一阶段,不是“再接一个搜索工具”,而是把搜索策略本身变成可观察、可实验、可回滚的软件资产。先建立可靠评估,再开放自动修改;先让 Agent 提议实验,再逐步扩大它的执行权限。这样的自迭代才可能带来稳定收益,而不是制造一个更昂贵、更难解释的搜索循环。