动手写一个搜索 Agent:从能跑通到受控运行
上一篇文章把 Chatbot/Workflow/RAG/Agent 四个概念的边界理清楚了。按照我的 roadmap,接下来两周正式上手写代码:先学 tool calling,做一个”网页搜索 + 总结”的最小 Agent;再做 prompt 约束、结构化输出和控制循环,把它从”能跑通”升级到”受控运行”。这篇文章就是这两周的完整记录——一个搜索 Agent 从 v1 到 v2 的演进过程,完整代码在文末的仓库里。
Tool Calling:Agent 的行动能力从哪来
上篇文章说过,Agent 的”行动能力”来自工具。LLM 本身只能生成文本,是 tool calling 机制让它能搜索、调 API、读写文件。这个机制的核心流程在任何模型上都一样:
1 | 定义工具(JSON Schema)→ 模型决策 → 开发者执行 → 结果回传 → 模型综合 |
写第一个 tool calling 示例时,有个分界线让我印象很深:模型不执行工具,它只决定调用什么。 模型返回 tool_calls 之后,所有的执行权都在我的代码手中——参数校验、权限检查、危险操作确认,都可以在执行前拦截。这个设计是后面所有安全控制的基石。
把这个流程放进一个循环,就得到了所有 Agent 系统的骨架:
1 | messages = [system_prompt, user_message] |
- 循环只有两个正常出口:模型主动结束(finish_reason 为 stop),或者达到最大轮次被强制终止——后者是必须有的安全机制,防止模型陷入无限的工具调用。
- 模型要调工具时,要先把它的决策记录(带 tool_calls 的 message)追加进历史,再把工具结果以 tool 角色追加进去,模型下一轮才能”看到”自己上一轮做了什么。
- 千问通过 OpenAI 兼容接口调用,
arguments是 JSON 字符串,执行前需要json.loads()解析。
这个 while 循环看起来平平无奇,但后面会看到,”能跑通”的循环和”生产可用”的循环之间差距巨大。
v1:最小可用的搜索 Agent
模型用的是阿里千问(qwen-plus),通过 OpenAI SDK 兼容接口调用(原计划用 OpenAI,中转服务折腾半天没通,千问有免费额度且国内直连)。两个工具都是只读的 Data 类工具:
- web_search:接入 DuckDuckGo(ddgs 库),返回精简的 title + url + snippet
- fetch_webpage:httpx 抓取网页 + readability 提取正文,过滤噪音
过程中踩的几个坑:
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| 1 | “北京”查不到天气数据 | 模型用中文填参数,mock 数据的 key 是英文 “Beijing” | description 里约束参数用英文 + 工具内部做中英文映射兜底 |
| 2 | duckduckgo_search 运行时 Warning | 这个包已更名为 ddgs | pip install ddgs 并更新 import |
| 3 | 搜索直接 ConnectError | DuckDuckGo 底层走 Bing,网络不通 | DDGS 构造函数里配置 proxy |
| 4 | fetch_webpage 在 Medium 等站点 403 | 反爬机制 | 当时没解,留到 v2(URL 黑名单) |
这两周里对我影响最大的一个认知是:工具描述(description)是影响 Agent 质量的最大杠杆。 读 Anthropic 的文档时看到这个说法还觉得浮于表面,亲手写代码才体会到差距——给 web_search 的 description 加上一句 “Does NOT fetch full content — use fetch_webpage for that” 之后,模型对两个工具的选择准确率明显提升,需要深入内容时会主动换用 fetch_webpage。
v1 跑了 7 个测试用例,结果不好:通过 4/7。失败的三个 case 很有代表性:
- 诺贝尔奖:模型直接从训练数据回答,完全跳过搜索。回答”看起来正确”,但无法验证时效性——如果训练数据里有错误信息,这就是一个自信的错误,用户完全无法分辨。最危险的失败,是看起来像成功的失败。
- LangGraph vs CrewAI 对比:搜索成功了,但 fetch_webpage 连续遇到 403(Medium、DataCamp 都有反爬),5 轮全部耗在重试抓取上,最终无输出。
- 解释 RAG:模型认为这是已知概念,直接回答,没有引用来源。
归纳下来,v1 暴露了三个问题:
- 模型倾向于”能回答就回答”,即使 system prompt 里要求先搜索——这是 LLM 的固有行为模式
- 输出格式不可控,没法程序化地校验和评测
- 工具连续失败时没有降级机制,一条路走到黑
第三周的升级就是冲着这三个问题去的。
先做实验,再改代码
升级前我设计的方案是:在同一个请求里同时传 tools 和 response_format,让模型既能调工具、又输出固定 JSON。动手改代码之前,先写了三组最小脚本验证千问 API 的实际行为——结果直接推翻了这个方案:
| 组合 | 结果 |
|---|---|
| 只传 tools | ✅ 正常调用工具 |
| tools + json_object | ❌ 400 报错(千问强制要求 messages 里包含 “json” 关键词,文档没明说) |
| tools + json_schema | 不报错,但 json_schema 把 tools 吃掉了——模型不调用工具,直接返回 JSON |
第三个结果最致命:它不是 API 层面的互斥(报错反而好排查),而是行为层面的互斥。如果跳过实验直接写代码,大概率要花一下午 debug 一个”工具怎么都不被调用”的灵异问题。
修正后的方案:主流程只传 tools;需要结构化输出时,等模型给出最终回答后,另发一个独立请求用 json_schema 做格式化。json_schema 的 strict: True 实测非常可靠——required 字段 100% 存在,enum 约束即使被 prompt 故意诱导也不会违反。
这件事给我的原则是:文档告诉你”支持什么”,但不会告诉你”组合使用时谁优先”。API 的实际行为必须用实验验证,不能只看文档。
v2:给 Agent Loop 装上三张安全网
v2 的升级围绕一个核心认知:Agent Loop 不是”信任模型的循环”,而是带安全网的决策循环——模型的每次决策都可能出错,需要在它决策之后、执行前后插入检查和保护。
第一层防御:翻转 System Prompt 的默认方向
v1 的 prompt 说”你可以使用搜索工具”(许可语气),模型经常无视。v2 翻转默认方向——必须先搜索,以下情况例外:
1 | # Core Rule: Search First |
再配上 3 个 few-shot examples(事实查询、技术概念、创意写作各一个),代替继续往上堆规则。这一层解决了约 80% 的问题:10 个测试 case 里 8 个在第一轮就做出了正确决策。
第二层防御:三个代码检查机制
剩下 20% 的 case,prompt 再怎么写都拦不住——模型”非常确信自己知道答案”的时候就是会跳过工具。这时候需要代码兜底。v2 在循环里加了三个检查机制,每个机制都明确定义三要素:触发条件、恢复行动、防护限制:
| 检查机制 | 触发条件 | 恢复行动 | 防护限制 |
|---|---|---|---|
| 纠正 | 模型 stop 时,规则判断”该搜没搜” | 注入纠正指令,强制重新决策 | 最多 1 次 |
| 降级 | 工具连续失败 ≥ 2 次 | 注入降级指令,让模型用已有 snippet 回答 | 最多 1 次 |
| 资源控制 | 上下文超过阈值 / 轮次到上限 | 警告 / 强制终止 | 每轮检查 |
“防护限制”这一列很重要——没有它,检查机制本身就可能把 Agent 拖进”检查 → 纠正 → 再检查”的死循环。纠正为什么最多 1 次?实测里跳过工具的 case 在纠正 1 次后都乖乖去搜索了;如果 1 次纠正还不够,说明 prompt 有更深层的问题,该去改 prompt 而不是加纠正次数。
其中”该搜没搜”的判断用的是一个纯规则引擎 should_have_searched():6 条搜索规则(时间指示词、概念查询、对比类、专有名词特征——驼峰命名、全大写缩写、版本号等)加 4 条排除规则(创意写作、数学、闲聊、写代码),28 条单元测试全部通过后才集成进循环。规则引擎无法理解语义,但成本为零、可单独测试,作为第一道过滤足够了。
把每一步决策记下来:结构化 Trace
v1 调试全靠 print,看完日志还要人脑重建”模型在第几轮做了什么决策”。v2 给每次运行记录三层结构化 trace:
1 | AgentTrace(整体:是否搜索过、是否触发纠正/降级、总耗时) |
后面会看到,本周最有价值的一个问题就是 trace 暴露出来的。
100% 通过率下面藏着的问题
v2 跑完测试,10/10 全部通过,v1 失败的三个 case 全部修复。如果只看通过率,到这里就可以收工了。但翻 trace 数据时发现了一个问题:10 个 case 里 7 个触发了纠正,其中 4 个是假阳性——模型在第 1 轮已经搜索过了,第 2 轮准备回答时被误判为”跳过工具”,又被强制多搜了一次。
根因不复杂:should_have_searched() 是个无状态函数,只看用户的原始问题,不知道对话已经进行到哪了。修复方式是在纠正前加一个有状态的前置检查:
1 | already_searched = any( |
already_searched遍历 trace 里的所有轮次,检查是否已经有过一次成功的 web_search 调用——这是一个有状态的判断,弥补了规则引擎只看静态输入的盲区。- 纠正的触发条件从两个变成三个:没纠正过、没搜索过、规则判断该搜索。三者同时满足才注入纠正指令。
修复后纠正触发率从 70% 降到 20%,平均轮次从 3.5 降到 2.5 左右。这个问题给我两个提醒:
- 假阳性不导致功能错误,只导致效率下降,所以更容易被忽视。 评测不能只看通过率,还要看代价——轮次、延迟、多余的 API 调用都是成本。
- 这个问题是 trace 暴露的。如果没有
correction_triggered这个字段,我根本不会知道 70% 的 case 被纠正过。Agent 系统的可改进性,取决于它的可观测性。
另一个有意思的数据是降级机制 0 次触发——v1 里连续 403 失败的那个 case,在 v2 里模型直接用搜索摘要回答了,根本没走到降级那一步。预防层(prompt)解决了问题,治疗层(代码)没被需要。这是好事,最好的安全网是永远不需要接住人的安全网——但它必须存在,就像数据库的备份恢复机制可能一年用不上一次,你也不会因此取消备份。
v1 → v2 量化对比
| 指标 | v1 | v2 |
|---|---|---|
| 测试用例数 | 7 | 10 |
| 通过率 | 4/7 (57%) | 10/10 (100%) |
| 工具使用准确率 | 57% | 100% |
| 平均轮次 | 3.0 | ~2.5 |
| 核心代码行数 | ~250 | ~600 |
| 单元测试 | 0 | 28 条 |
几个核心认知
- Prompt 优化和代码检查是互补关系,不是替代关系。 Prompt 是预防层,解决 80% 的问题;代码检查是检测层,捕获漏网之鱼。最佳策略是让 prompt 层尽可能强、让代码层尽可能不被触发,但两层都必须存在——和安全领域的纵深防御是一个道理。
- Trace 是 Agent 工程的基础设施,不是可有可无的 debug 工具。 不能观测的系统就不能被可靠地改进。
- API 的组合行为必须用实验验证。 文档不会告诉你 tools 和 response_format 同时传的时候谁说了算。
- 手写控制循环的复杂度是指数增长的。 v1 的循环只有两个分支,很清晰;v2 加了三个检查机制后,主循环 150+ 行、嵌套 4-5 层,每加一个机制分支数接近翻倍。这也让我提前理解了 LangGraph 这类框架存在的意义——把纠正、降级、回答拆成状态图上的独立节点,是管理这种复杂度的更好工具(这是 roadmap 第六周的内容,到时候再写)。
按照 roadmap,下一步是 RAG。搜索 Agent 的”搜索 → 抓取 → 基于内容回答”和 RAG 的”检索 → 取文档 → 基于文档回答”在逻辑上几乎同构,Agent Loop、trace、规则引擎应该都能直接复用,到时候验证一下这个判断。
本文的完整代码(含测试用例和 trace 报告)在 ai-agent-lab 仓库的 week_2 和 week_3 目录。
(全文完)
附:引用
- Anthropic - Building Effective Agents:Agent 工程实践与”先简单后复杂”原则
- Anthropic - Writing Tools for Agents:工具设计方法论
- OpenAI - A Practical Guide to Building AI Agents:产品与工程视角的 Agent 设计
- Anthropic - Effective Context Engineering:上下文工程与”正确高度”原则





