DEV Community

goodlords
goodlords

Posted on Edited on

从零手写 Agent(四):多 Tool 与联网检索

从零手写 Agent(四):多 Tool 与联网检索

前面三篇,我基本把一个 Agent 最核心的骨架搭出来了:

  • 第一篇:Tool Calling,让模型可以调用外部能力。
  • 第二篇:messages,让 Agent 有了短期记忆。
  • 第三篇:上下文超长,开始处理 Context Window 和历史消息治理。

做到这里,下一步很自然:

如果我再加两个 Tool,让 Agent 能搜索互联网、读取网页,是不是要继续给 Loop 加 if/else?

答案是:不用。

这一篇我给之前的 Calculator Agent 再加两个 Tool:

  • search_web:搜索互联网
  • fetch_url:读取网页

最终变成:

用户问题
   ↓
LLM
   ↓
选择 Tool
   ├── calculator
   ├── search_web
   └── fetch_url
   ↓
Tool 执行
   ↓
结果放回 messages
   ↓
LLM 再判断
   ↓
继续 Tool / 直接回答
Enter fullscreen mode Exit fullscreen mode

Loop 还是之前那个 Loop。

真正发生变化的是:

Tool 越来越多以后,Agent 的能力边界主要由 Tool Registry 决定,而不是由 Loop 决定。

系列目录:GitHub docs(https://github.com/zealot2002/myagent/blob/main/docs/00-系列导读.md)


1. 为什么还是同一个 Loop?

之前的核心代码大概就是:

while steps < MAX_STEPS:
    prepare_messages(messages)

    response = llm(
        messages,
        tools=ALL_TOOLS,
    )

    if response.tool_calls:
        for call in response.tool_calls:
            result = execute_tool(
                call.name,
                call.arguments,
            )

            messages.append({
                "role": "tool",
                "content": result,
            })

        continue

    return response.content
Enter fullscreen mode Exit fullscreen mode

现在增加 search_web 和 fetch_url,这里基本不用动。

只需要:

ALL_TOOLS = [
    CALCULATOR,
    SEARCH_WEB,
    FETCH_URL,
]
Enter fullscreen mode Exit fullscreen mode

然后:

response = llm(
    messages,
    tools=ALL_TOOLS,
)
Enter fullscreen mode Exit fullscreen mode

模型自己决定:

要不要搜索?
    ↓
search_web

拿到 URL 了
    ↓
fetch_url

还需要计算
    ↓
calculator

信息已经够了
    ↓
直接回答
Enter fullscreen mode Exit fullscreen mode

所以从 Agent Loop 的角度看:

Tool 变多
    ↓
Registry 变大
    ↓
模型可选择的动作变多
Enter fullscreen mode Exit fullscreen mode

而不是:

Tool 变多
    ↓
Loop 增加大量 if/else
Enter fullscreen mode Exit fullscreen mode

这其实是这一篇最重要的结论。


2. 多 Tool 的关键:先把能力边界划清楚

这次增加的两个 Tool 看起来都和“联网”有关,但我没有把它们合成一个:

web_tool
Enter fullscreen mode Exit fullscreen mode

而是拆成:

search_web
fetch_url
Enter fullscreen mode Exit fullscreen mode

原因很简单:

搜索和读取网页,本来就是两个不同的动作。

Tool 输入 输出 适合解决
calculator 数学表达式 数值 精确计算
search_web 搜索词 标题、URL、摘要 不知道去哪里找
fetch_url 完整 URL 网页正文 已经知道去哪看

例如:

“Python 3.13 是什么时候发布的?”
Enter fullscreen mode Exit fullscreen mode

一开始并不知道哪个页面有答案:

search_web
Enter fullscreen mode Exit fullscreen mode

拿到:

Python 3.13.0
https://www.python.org/downloads/release/python-3130/
Enter fullscreen mode Exit fullscreen mode

这时候才适合:

fetch_url
Enter fullscreen mode Exit fullscreen mode

去读取具体页面。

如果一开始就让 fetch_url 负责搜索,就会把“搜索”和“读取”混成一个能力,Tool 的职责越来越模糊。

所以这里有一个很实用的原则:

一个 Tool 最好表达一个清晰的动作,而不是把一整段工作流塞进 Tool。


2.1 search_web

给模型看的 Tool 描述大致是:

用于搜索互联网信息、新闻、资料和网页链接。

不要用于:
- 纯数学计算
- 用户已经提供完整 URL 的情况
- 普通概念问答
Enter fullscreen mode Exit fullscreen mode

参数:

{
  "query": "Python 3.13 release date",
  "max_results": 5
}
Enter fullscreen mode Exit fullscreen mode

其中:

query
Enter fullscreen mode Exit fullscreen mode

是搜索词。

max_results
Enter fullscreen mode Exit fullscreen mode

限制返回结果数量,当前范围:

1 ~ 10
Enter fullscreen mode Exit fullscreen mode

返回给模型的是普通字符串,例如:

[1] Python 3.13.0
URL: https://www.python.org/downloads/release/python-3130/
Snippet: Python 3.13.0 was released...

---

[2] What's New In Python 3.13
URL: ...
Snippet: ...
Enter fullscreen mode Exit fullscreen mode

最后还是:

{
  "role": "tool",
  "content": "..."
}
Enter fullscreen mode Exit fullscreen mode

所以对 Agent Loop 来说,搜索结果没有特殊待遇。

它就是一次 Observation。

如果搜索失败,也只是:

Error: search failed (...)
Enter fullscreen mode Exit fullscreen mode

模型拿到这个结果之后,可以:

  • 换一个搜索词重试
  • 换一个 Tool
  • 或者直接告诉用户当前无法获取

Loop 本身不需要知道“搜索失败了”。


2.2 fetch_url

fetch_url 的职责更加单一:

用户或者前一步搜索已经给出了 URL,现在把这个网页内容读回来。

参数:

{
  "url": "https://www.python.org/"
}
Enter fullscreen mode Exit fullscreen mode

当前只接受:

http://
https://
Enter fullscreen mode Exit fullscreen mode

返回的是经过简单处理后的网页文本。

HTML 里的:

<script>
<style>
Enter fullscreen mode Exit fullscreen mode

等内容会被去掉。

同时限制返回长度,大约:

8000 字符
Enter fullscreen mode Exit fullscreen mode

超过以后:

...(fetch truncated in tool)
Enter fullscreen mode Exit fullscreen mode

这里有两个原因。

第一,网页本身可能非常长。

第二,也是第三篇刚处理过的问题:

Tool 返回太长,最终还是会进入 messages,继续消耗 Context Window。

所以现在其实形成了两层控制:

fetch_url
    ↓
先限制网页输出长度
    ↓
messages
    ↓
prepare_messages()
    ↓
必要时继续做 Tool 内容截断 / 历史滑窗
Enter fullscreen mode Exit fullscreen mode

也就是说,第三篇的上下文治理并没有因为增加 Web Tool 而失效,反而变得更重要了。


3. 三个 Tool 怎么注册?

最终:

ALL_TOOLS = [
    CALCULATOR,
    SEARCH_WEB,
    FETCH_URL,
]
Enter fullscreen mode Exit fullscreen mode

每一个 Tool 还是之前的结构:

Tool(
    name="search_web",
    schema=...,
    fn=search_web,
)
Enter fullscreen mode Exit fullscreen mode

Agent Loop 只需要:

execute_tool(name, arguments)
Enter fullscreen mode Exit fullscreen mode

至于:

search_web 到底怎么搜?
fetch_url 到底怎么抓?
Enter fullscreen mode Exit fullscreen mode

这些都属于 Tool 自己的事情。

目录结构现在大概变成:

agent/
├── tools/
│   ├── calculator.py
│   ├── search_web.py
│   ├── fetch_url.py
│   └── __init__.py
├── loop.py
└── state.py
Enter fullscreen mode Exit fullscreen mode

依赖增加:

ddgs
httpx
Enter fullscreen mode Exit fullscreen mode

相关环境变量:

SEARCH_TIMEOUT_SEC
SEARCH_BUDGET_SEC
FETCH_MAX_BYTES
FETCH_TIMEOUT_SEC
Enter fullscreen mode Exit fullscreen mode

4. search_web 里面到底怎么实现?

这里有一个容易混淆的地方:

Agent 是多 Tool,不代表一个 Tool 内部只能访问一个数据源。

我现在暴露给模型的仍然只有:

search_web
Enter fullscreen mode Exit fullscreen mode

但 search_web 内部可以尝试多个搜索来源。

当前学习版采用的是串行 Failover:

ddgs
  ↓ 失败
DuckDuckGo Instant Answer
  ↓ 失败
Wikipedia
  ↓ 失败
Qwant
  ↓ 失败
SearXNG
Enter fullscreen mode Exit fullscreen mode

哪个先成功,就返回哪个结果。

这样做的目的不是实现一个完整搜索引擎,而是让这个小项目在没有 API Key 的情况下,也能尽量跑起来。

单个来源有:

SEARCH_TIMEOUT_SEC
Enter fullscreen mode Exit fullscreen mode

整个搜索还有:

SEARCH_BUDGET_SEC
Enter fullscreen mode Exit fullscreen mode

避免所有来源轮流超时,把 Agent 卡很久。

运行:

python main.py -v --trace "Python 3.13 release date"
Enter fullscreen mode Exit fullscreen mode

可以看到:

search provider=ddgs success
search results=...
Enter fullscreen mode Exit fullscreen mode

或者:

search provider=ddgs failed
search provider=wikipedia success
Enter fullscreen mode Exit fullscreen mode

这些信息属于日志,不应该直接塞进模型的 Observation。

模型只需要看到统一格式:

title
url
snippet
Enter fullscreen mode Exit fullscreen mode

这也是为什么多源搜索最好封装在一个 Tool 里面。


4.1 为什么不直接暴露多个搜索 Tool?

比如:

search_duckduckgo
search_wikipedia
search_bing
search_qwant
Enter fullscreen mode Exit fullscreen mode

看起来很灵活,但模型一下子多了很多选择。

而这些 Tool 的本质动作其实都是:

query → 搜索结果
Enter fullscreen mode Exit fullscreen mode

更合理的边界是:

模型
 ↓
search_web
 ↓
内部决定数据源
Enter fullscreen mode Exit fullscreen mode

未来想从串行改成并发,也应该改:

search_web
Enter fullscreen mode Exit fullscreen mode

内部,而不是修改 Agent Loop。


5. 串行还是并行?

如果继续往下做,多源搜索很自然会遇到这个问题。

现在是:

A
↓
失败
B
↓
失败
C
Enter fullscreen mode Exit fullscreen mode

最简单,但延迟可能比较高。

另一种方式是:

        ┌─ A
query ──┼─ B
        └─ C
             ↓
        merge + dedupe
             ↓
          ranking
             ↓
        SearchHit[]
Enter fullscreen mode Exit fullscreen mode

例如内部统一成:

@dataclass
class SearchHit:
    title: str
    url: str
    snippet: str
    source: str
Enter fullscreen mode Exit fullscreen mode

不同搜索源负责:

原始响应
   ↓
adapter
   ↓
SearchHit
Enter fullscreen mode Exit fullscreen mode

最后再统一格式化成:

role: tool
Enter fullscreen mode Exit fullscreen mode

这是后面如果要继续升级时比较自然的方向。

但这篇的学习版没有把复杂度继续往上堆,仍然采用串行 Failover。


6. 多 Tool 真正跑起来是什么样?

假设用户问:

查一下 Python 官方站是做什么的,并把 123*456 算出来。
Enter fullscreen mode Exit fullscreen mode

一个可能的轨迹是:

User
 ↓
LLM
 ↓
search_web
 ↓
搜索结果
 ↓
LLM
 ↓
fetch_url
 ↓
网页内容
 ↓
LLM
 ↓
calculator
 ↓
56088
 ↓
LLM
 ↓
Final Answer
Enter fullscreen mode Exit fullscreen mode

注意:

这个顺序不是写死的。

模型也可能:

calculator
 ↓
search_web
 ↓
fetch_url
Enter fullscreen mode Exit fullscreen mode

甚至只搜索,不抓取网页。

所以 Agent Loop 的职责依然只是:

LLM → Tool → Observation → LLM
Enter fullscreen mode Exit fullscreen mode

至于下一步是什么,由模型决定。


7. 实际测试:Tool 能调用,不代表结果就好

这一部分我觉得比单纯展示代码更有价值。

因为加上联网 Tool 后,很容易产生一个错觉:

“现在 Agent 会搜索了,所以它已经是 Research Agent 了。”

实际并不是。

我用:

qwen2.5:3b
Enter fullscreen mode Exit fullscreen mode

做了两个测试。


7.1 测试一:直接问“今日新闻”

执行:

python main.py -v "今日新闻摘要"
Enter fullscreen mode Exit fullscreen mode

模型实际发出的搜索词是:

Today's news summary
Enter fullscreen mode Exit fullscreen mode

并且:

max_results=1
Enter fullscreen mode Exit fullscreen mode

ddgs 成功返回了结果,但结果比较尴尬:

Google News
Enter fullscreen mode Exit fullscreen mode

页面本身更像一个新闻门户入口,摘要主要是:

登录
地区
导航
Enter fullscreen mode Exit fullscreen mode

最终 Agent 基本变成:

这里有一个今日新闻链接……
Enter fullscreen mode Exit fullscreen mode

也就是说:

Tool 调用成功
        ≠
得到高质量研究结果
Enter fullscreen mode Exit fullscreen mode

问题并不是网络失败。

而是:

用户问题太宽
    ↓
模型生成了英文宽泛 Query
    ↓
只取 1 条结果
    ↓
命中了门户首页
    ↓
没有继续 fetch
    ↓
最终只能给链接
Enter fullscreen mode Exit fullscreen mode

7.2 测试二:把搜索目标说具体

换一个:

python main.py -v --trace '用 search_web 检索「新华社 今日要闻」或「BBC world news today」,根据返回的标题和摘要用中文列 3 条,不要只给链接。'
Enter fullscreen mode Exit fullscreen mode

这次模型搜索:

新华社 今日要闻
Enter fullscreen mode Exit fullscreen mode

返回 5 条结果。

结果里出现了比较明确的新华社页面:

标题
URL
Snippet
Enter fullscreen mode Exit fullscreen mode

最后模型能够根据这些摘要整理出 3 条中文内容。

这次没有调用 fetch_url。

其实已经够解释一个很重要的问题:

搜索质量很大程度取决于模型给搜索 Tool 的 Query,以及搜索结果本身是什么类型。

两个测试对比:

测试一 测试二
Query 今日新闻 新华社 今日要闻
Query 语言 英文 中文
max_results 1 5
结果 新闻门户 具体新闻页面
fetch 没有 没有
最终结果 基本给链接 能整理 3 条摘要
Tool 成功 成功

所以这里暂时不能把第二个测试说成“已经可以做今日新闻调研”。

它只是说明:

search_web
    ↓
返回质量还可以的 Observation
    ↓
小模型能够继续加工
Enter fullscreen mode Exit fullscreen mode

另外测试里还出现了一个很典型的问题:

模型把 Tool 返回的:

权威快报
Enter fullscreen mode Exit fullscreen mode

写成了:

权力建报
Enter fullscreen mode Exit fullscreen mode

这意味着:

Tool 返回的内容进入模型以后,模型仍然可能改写甚至产生错误。

这也是下一篇要解决的引用问题。


8. 这里到底算不算 Research Agent?

如果只看能力链路:

用户问题
 ↓
search
 ↓
fetch
 ↓
多步推理
 ↓
answer
Enter fullscreen mode Exit fullscreen mode

现在已经具备一个最小的 Web Research Agent 雏形。

但我更愿意把它叫:

学习版 Research Agent,而不是完整的调研系统。

目前已经有:

  • 多 Tool
  • Web Search
  • 网页读取
  • 多步 Tool Calling
  • 短期记忆
  • Context Trim
  • Trace / Debug

但距离真正的“调研交付”还差几个关键东西。

第一,缺少明确的调研流程

现在主要依赖模型临场决定:

搜什么
搜几次
什么时候 fetch
什么时候停止
Enter fullscreen mode Exit fullscreen mode

还没有:

规划
→ 搜索
→ 补充搜索
→ 交叉验证
→ 汇总
Enter fullscreen mode Exit fullscreen mode

这样的显式流程。

第二,来源没有被结构化保存

现在:

search_web
Enter fullscreen mode Exit fullscreen mode

返回:

标题 + URL + 摘要
Enter fullscreen mode Exit fullscreen mode

模型读完之后自己组织答案。

如果模型改写错了,Agent 很难约束它:

这个说法到底来自哪个 URL?
Enter fullscreen mode Exit fullscreen mode

第三,还没有真正的报告产物

现在最终结果还是:

response.content
Enter fullscreen mode Exit fullscreen mode

如果要做成真正的调研 Agent,应该有类似:

summary
sections
references
Enter fullscreen mode Exit fullscreen mode

这样的结构,并且最终能保存成:

reports/xxx.md
Enter fullscreen mode Exit fullscreen mode

这就是下一篇准备解决的问题。


9. 多 Tool 以后,Agent 的边界到底在哪里?

做到这里,可以重新看一下整个 Agent。

第一篇的时候:

LLM
+
calculator
+
Loop
Enter fullscreen mode Exit fullscreen mode

现在:

LLM
+
calculator
+
search_web
+
fetch_url
+
Loop
+
State
+
Context Governance
Enter fullscreen mode Exit fullscreen mode

但 Loop 还是:

while steps < MAX_STEPS:
    prepare_messages(messages)

    response = llm(
        messages,
        tools=ALL_TOOLS,
    )

    if response.tool_calls:
        ...
        continue

    return response.content
Enter fullscreen mode Exit fullscreen mode

所以我现在越来越倾向于把 Agent 理解成:

Agent
=
LLM
+
Tool Registry
+
Loop
+
State
+
Context Governance
Enter fullscreen mode Exit fullscreen mode

其中:

Tool Registry
Enter fullscreen mode Exit fullscreen mode

决定 Agent 能做什么。

LLM
Enter fullscreen mode Exit fullscreen mode

决定当前 想做什么。

Loop
Enter fullscreen mode Exit fullscreen mode

负责把:

想做
→ 执行
→ 观察
→ 再想
Enter fullscreen mode Exit fullscreen mode

串起来。

而:

State / Context Governance
Enter fullscreen mode Exit fullscreen mode

负责让这个过程不要因为历史消息和 Tool 输出越来越长而失控。


10. 本地运行

安装依赖:

pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

启动模型:

ollama pull qwen2.5:7b
Enter fullscreen mode Exit fullscreen mode

直接测试搜索:

python -c "from agent.tools.search_web import search_web; print(search_web('Python 3.13 release date', 2))"
Enter fullscreen mode Exit fullscreen mode

测试网页读取:

python -c "from agent.tools.fetch_url import fetch_url; print(fetch_url('https://www.python.org/downloads/release/python-3130/')[:600])"
Enter fullscreen mode Exit fullscreen mode

让 Agent 自己调用搜索:

python main.py -v --trace '请用 search_web 查 Python 3.13 正式版发布日期,并写出结果里的来源标题。'
Enter fullscreen mode Exit fullscreen mode

直接测试 fetch_url:

python main.py -v --trace '请用 fetch_url 读取 https://www.python.org/downloads/release/python-3130/ 并中文概括发布日期。'
Enter fullscreen mode Exit fullscreen mode

测试多 Tool:

python main.py -v "Python 3.12 有什么新特性?简述并给出来源。"
Enter fullscreen mode Exit fullscreen mode

如果需要交互调试:

python main.py -i --trace
Enter fullscreen mode Exit fullscreen mode

搜索相关功能依赖网络。

另外,如果终端出现:

dquote>
Enter fullscreen mode Exit fullscreen mode

这类提示,通常是 Shell 引号没有闭合。命令里的整句提示建议用英文单引号包起来。


11. 收束

演进线:Tool → messages → trim → 多 Tool 联网。第四篇一句话:

加能力 = 加 Tool 并注册;Loop 形状不变。

search_web(及后续的 search_hn、fetch_rss)解决「去哪找」;fetch_url 解决「打开已知 URL」。失败、超时、HTML 简抽、结果截断都封在 Tool 内。§4.2 的对比说明:Tool 成功 ≠ 研究结果合格 — query 宽窄与是否继续 fetch 同样关键。

边界应划清:Tool Contract、Registry、State、Context Governance。

Repo:zealot2002/myagent


下一篇

《从零手写 Agent(五):调研报告、引用与落盘——Loop 仍不变》——save_markdown、sources、save nudge。

系列目录:GitHub docs(https://github.com/zealot2002/myagent/blob/main/docs/00-系列导读.md)


附录:当前 Tool Registry(发表时)

ALL_TOOLS = [
    CALCULATOR,
    SEARCH_WEB,
    SEARCH_HN,
    FETCH_RSS,
    FETCH_URL,
    SAVE_MARKDOWN,
]
Enter fullscreen mode Exit fullscreen mode

Top comments (0)