上一章我们构建了一个最小化的 Agent,成功验证了“模型在需要时会调用工具”这一流程。本章将深入探讨工具(Tool)的定义方式、模型如何调用工具,以及如何利用一系列工具搭建一个轻量级的“知识库问答系统”。
首先明确几个核心观点:对于 Agent 而言,工具并非锦上添花的附加功能,而是决定其能否真正执行任务的关键。没有工具,模型只能依赖参数中存储的记忆和训练数据中的知识来回答问题,效果十分有限。一旦接入工具,模型便拥有了“主动获取信息”的能力——无论是读取文件、调用接口还是计算数据,这些任务均可交由工具完成,模型只需专注于决策与信息整合。
1. 目标:用 Tools 实现一个最小化的本地知识库问答
今天的目标非常明确:
- 帮助你建立对 Agent 中 Tools 的“可运行”直觉——模型如何选择工具、传递参数、以及读取工具返回的结果
- 利用一份本地文本文件作为“知识库”,构建一个极简的问答流程:问题 → 提取关键词 → 在文件中检索 → 获取证据 → 生成答案
这里的“知识库”极其朴素:仅为一个文本文件,不涉及向量化、Embedding 或数据库。核心关注点只有一个:Tool Use 的闭环是否能够顺畅运行。
2. 材料准备:三体三部曲内容简介(santi.txt)
首先介绍材料。我们准备了一段《三体》三部曲的内容简介,作为本地知识库文本文件。(内容来源:bbs.mfpud.com/loadream-33…)
文件大致内容如下(摘录几行以便了解格式):
第一部:地球往事简介天文学家叶文洁... 红岸工程... 向宇宙发出地球文明的第一声啼鸣...第二部:黑暗森林简介... “面壁计划” ... 罗辑 ...第三部死神永生简介身患绝症的云天明买下一颗星星送给暗恋着的大学同学程心...
后续提出的问题会尽量贴合这些段落,例如:“云天明买了什么送给谁?”这样模型便可通过检索从文本中找到证据并给出答案。
3. 工具准备:grep 与 read_lines
为了让 Agent 能够“动手”查询本地文件,我们需要准备两个基础工具:
grep:根据关键词或正则表达式在文件中搜索,返回匹配行号(先定位证据位置)read_lines:按行号范围读取文件原文(将证据内容提供给回答模型)
代码可直接通过 Claude 生成。核心代码示例如下(省略了错误处理细节,保留关键接口形态):
import re
from pathlib import Path
from typing import Optional
from langchain_core.tools import tool
@tool
def grep(pattern: str, path: str, recursive: bool = False) -> dict:
'''在文件或目录中搜索匹配 pattern 的行,只返回行号。配合 read_lines 使用查看具体内容。'''
path = Path(path).expanduser().resolve()
regex = re.compile(pattern)
lines = path.read_text(encoding="utf-8", errors="replace").splitlines(True)
matched_lines = [i + 1 for i, line in enumerate(lines) if regex.search(line)]
return {
"pattern": pattern,
"results": [{"file": str(path), "line_numbers": matched_lines}]
}
@tool
def read_lines(path: str, start_line: int, end_line: int) -> dict:
'''读取文件指定行范围的内容。
Args:
path: 文件路径
start_line: 起始行(从 1 开始,含)
end_line: 结束行(含)
encoding: 文件编码'''
path = Path(path).expanduser().resolve()
lines = path.read_text(encoding="utf-8").splitlines(True)
selected = lines[start_line - 1 : end_line]
content = "".join(f"{start_line + i}: {line}" for i, line in enumerate(selected))
return {"path": str(path), "content": content}
需要重点理解两点:
@tool装饰器将一个普通 Python 函数包装为“可被模型调用的工具”,包括函数名、参数 schema 以及返回值结构- 工具返回值应尽量结构化(此处用 dict),因为后续的 agent/chain 会将其视为“可解析的环境反馈”进行消费,而非纯文本
4. 代码:四个 Agent 串联成一条检索链路(keyword → search → read → answer)
本示例将一个最小的“文本知识库问答”拆分为 4 个小 Agent,各司其职:
- keyword agent:从问题中提取关键词
- search agent:针对每个关键词调用
grep查找行号 - read agent:根据行号调用
read_lines读取证据原文 - answer agent:将“问题 + 证据”组织为最终答案(此步骤无需工具)
4.1 先准备模型与工具
from langchain_openai import ChatOpenAI
from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
from tools import file
agent_tools = [file.read, file.grep, file.read_lines]
llm_with_tools = ChatOpenAI(model="gpt-4", temperature=0.1).bind_tools(agent_tools)
llm = ChatOpenAI(model="gpt-4", temperature=0.1)
关键点:
bind_tools(agent_tools)会将工具的 schema 一同发送给模型,使模型具备“生成 tool call”的能力- 后续的 answer agent 仅需生成自然语言答案,不需要工具,因此使用不带 tools 的
llm即可
4.2 定义四个 Agent(各自一套 Prompt)
keyword agent 的 prompt 要求仅输出关键词列表(JSON list),便于后续步骤处理:
keyword_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个关键词提取助手,请从用户问题中提取关键词。"),
("human", "请从以下问题中提取关键词:{question},结果按 JSON list 输出"),
("placeholder", "{agent_scratchpad}"),
])
keyword_agent = create_tool_calling_agent(llm_with_tools, agent_tools, keyword_prompt)
keyword_agent_executor = AgentExecutor(agent=keyword_agent, tools=agent_tools, verbose=True)
search agent 的 prompt 将驱动其对每个关键词分别调用 grep:
search_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个文件搜索助手,用于搜索文件内容"),
("human", "请根据关键词{keywords}在文件{path}中搜索,分别调用 grep,返回关键词、行号、摘要"),
("placeholder", "{agent_scratchpad}"),
])
search_agent = create_tool_calling_agent(llm_with_tools, agent_tools, search_prompt)
search_agent_executor = AgentExecutor(agent=search_agent, tools=agent_tools, verbose=True)
read agent 根据 search 的输出再调用 read_lines 读取原文:
read_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个文件助手,用于读取文件内容"),
("human", "请根据关键词行号{lineNo}在文件{path}中读取对应内容。"),
("placeholder", "{agent_scratchpad}"),
])
read_agent = create_tool_calling_agent(llm_with_tools, agent_tools, read_prompt)
read_agent_executor = AgentExecutor(agent=read_agent, tools=agent_tools, verbose=True)
最后的 answer agent 无需工具,直接将“问题 + 证据”输入模型:
answer_prompt = ChatPromptTemplate.from_template("""
根据以下搜索结果,回答问题:
问题:{question}
搜索结果:{search_results}
请根据搜索结果给出准确的答案。
""")
answer_chain = answer_prompt | llm | StrOutputParser()
4.3 将四个 Agent 串联成 Chain(固定流程的工作流)
接下来,我们将这些工具和 Agent 串联起来。这一部分属于经典的工作流:每一步由代码编排好,模型仅在每一步内部“决定如何调用工具”。
FILE_PATH = "santi.txt"
keyword_result = keyword_agent_executor.invoke({"question": question})
keywords = keyword_result["output"]
search_result = search_agent_executor.invoke({"keywords": keywords, "path": FILE_PATH})
search_results = search_result["output"]
read_result = read_agent_executor.invoke({"lineNo": search_results, "path": FILE_PATH})
answer = answer_chain.invoke({"question": question, "search_results": read_result})
至此,一个最小的“本地知识库问答”系统搭建完成:它不依赖模型记忆或幻觉,而是通过工具查找证据、读取证据,并基于证据生成答案。
运行 Agent 测试效果
我们查询问题“云天明买了什么送给谁”:
answer = answer_chain.invoke({
"question": "云天明买了什么送给谁",
"search_results": read_result
})
最终结果:
=== 步骤4: 生成答案 ===
最终答案: 云天明买了一颗星星送给暗恋着的大学同学程心。
结果较为清晰。不过值得注意:search agent 的摘要可能包含模型自己总结的内容,并非原文。read agent 这一步通过 read_lines 获取原文作为 ground truth,恰好可以纠正任何摘要幻觉。
下面用流程图展示实际运行的链路(示例问题:云天明买了什么送给谁)。
flowchart TD
Q["用户问题
云天明买了什么送给谁"]
K["keyword agent
输出关键词 JSON list
[云天明, 买, 送, 谁]"]
Q --> K
S["search agent
逐个关键词检索行号"]
K --> S
G1["grep(云天明)
命中: 第 17 行"]
G2["grep(买)
命中: 第 17 行"]
G3["grep(送)
命中: 第 17 行"]
G4["grep(谁)
未命中: 0"]
S --> G1
S --> G2
S --> G3
S --> G4
L["聚合 line_numbers
[17]"]
G1 --> L
G2 --> L
G3 --> L
G4 --> L
H["注意:search agent 的摘要
可能是模型生成
不等于原文证据"]
S -.-> H
R["read agent
按行号取证据"]
L --> R
E["ground truth 证据
第 17 行原文
云天明买下一颗星星送给程心"]
R --> E
A["answer chain
基于问题+证据生成答案"]
E --> A
OUT["最终答案
云天明买了一颗星星送给暗恋的大学同学程心"]
A --> OUT
图中每个节点对应日志中的各个阶段:
- keyword agent 负责将问题拆解为可检索的关键词(包含实体与动作)
- search agent 通过多次
grep将关键词映射到证据行号(即使“谁”未命中,也不影响后续流程) - read agent 用
read_lines获取原文作为 ground truth(纠正任何摘要幻觉) - answer chain 仅进行“基于证据的改写”,不提供工具,也不允许凭空编造
5. 拆解:LLM Tool Call 的真实过程(基于 Prompt Messages)
接下来,我们深入分析 LLM 工具调用的内在机制。对上述 search_chain 的实际 prompt 进行输出解析。总体流程分为三个阶段:
- 第一次模型调用:模型决定需要调用哪些工具以及使用什么参数(通常不输出自然语言)
- 工具执行:框架根据模型的请求依次调用工具,并将结果作为
ToolMessage追加回对话 - 第二次模型调用:模型读取所有
ToolMessage,将工具结果整理为最终输出(不再发起工具调用)
打印出的 Message List 如下:
--- Message 1 (SystemMessage) ---
Content: 你是一个文件搜索助手,用于搜索文件内容
--- Message 2 (HumanMessage) ---
Content: 请根据关键词["云天明", "买", "送", "谁"](JSON list格式)在指定文件路径santi.txt的文件中进行搜索。你需要使用 grep 工具来搜索文件,找出包含关键词的行号位置。请为每个关键词分别调用 grep 工具进行搜索。文件路径: santi.txt关键词...
--- Message 3 (AIMessage) ---
Content:
--- Message 4 (ToolMessage) ---
Content: {'pattern': '云天明', 'total_matches': 1, 'files_matched': 1, 'results': [{'file': 'santi.txt', 'match_count': 1, 'line_numbers': [17]}]}
--- Message 5 (ToolMessage) ---
Content: {'pattern': '买', 'total_matches': 1, 'files_matched': 1, 'results': [{'file': 'santi.txt', 'match_count': 1, 'line_numbers': [17]}]}
--- Message 6 (ToolMessage) ---
Content: {'pattern': '送', 'total_matches': 1, 'files_matched': 1, 'results': [{'file': 'santi.txt', 'match_count': 1, 'line_numbers': [17]}]}
--- Message 7 (ToolMessage) ---
Content: {'pattern': '谁', 'total_matches': 0, 'files_matched': 0, 'results': []}
5.1 各个 Messages 的含义是什么?
在日志中,消息大致按角色分类如下:
- SystemMessage:定义角色——“你是一个文件搜索助手”
- HumanMessage:定义任务——“对每个关键词分别调用 grep,查找行号”
- AIMessage(内容为空):这是“发起工具调用”的那一轮模型输出
- ToolMessage ×4:框架执行了 4 次
grep,将每次返回的 dict 作为 ToolMessage 放回历史 - AIMessage(有内容):第二轮模型输出,将四次 grep 的结果整理为“搜索结果如下……”
5.2 ToolMessage 的作用:将外部世界信息塞回上下文
在 search agent 这一步,grep 工具返回的是结构化 dict:
pattern:本次搜索的关键词results[].line_numbers:命中的行号数组total_matches / files_matched:统计数据
LangChain 将这些 dict 作为 ToolMessage 放回 messages,使模型在第二轮能够“逐条读取”并进行聚合。
本次结果非常干净:
"云天明" / "买" / "送"均命中第 17 行"谁"未命中
因此第二轮模型自然会生成一个“按关键词列出行号”的汇总。
5.4 用图展示完整的 Tool Call 过程(含对应消息)
sequenceDiagram
participant U as HumanMessage
(任务描述)
participant AE as AgentExecutor
(编排器)
participant L as LLM
(tool-calling)
participant G as Tool: grep
Note over AE: messages[1]=SystemMessage
messages[2]=HumanMessage
AE->>L: 第一次 LLM 调用
输入: System+Human
Note over L: 输出: AIMessage(Content 可能为空)
并携带 tool_calls:
grep("云天明") / grep("买") / grep("送") / grep("谁")
L-->>AE: tool_calls 列表
AE->>G: 调用 grep(pattern="云天明", path=...)
G-->>AE: ToolMessage #1
{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="买", path=...)
G-->>AE: ToolMessage #2
{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="送", path=...)
G-->>AE: ToolMessage #3
{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="谁", path=...)
G-->>AE: ToolMessage #4
{results:[], ...}
AE->>L: 第二次 LLM 调用
输入: System+Human+ToolMessages
Note over L: 输出: AIMessage(自然语言汇总)
Tool Calls: []
L-->>AE: 搜索结果如下...
从上述描述可以看出,AgentExecutor 中的 tool_call 本质上是一个循环——对 LLM 返回的 tool_call 逐一执行函数调用。
6. 使用 Tavily MCP 进行远程查询(本地无结果时回退)
前面使用的 grep / read_lines 都是本地工具:它们直接访问本地文件系统,优势在于速度快、可控性强、结果稳定;缺点在于信息范围仅限于给定的文件。
当本地知识库无法提供答案时,一种自然的升级路径是:将“互联网搜索”也封装为 Agent 可调用的工具。然而,这类工具往往是远程服务(需要鉴权、网络、限流等),直接在本地编写大量 SDK 代码会使 Agent 逻辑变得臃肿。
这正是 MCP(Model Context Protocol)要解决的问题:通过统一协议将“远端工具”接入 Agent 的工具列表。
6.1 MCP 是什么:将工具变成“可插拔的远程服务”
可以将 MCP 理解为一个三层结构:
- Host(你的应用 / LangChain 代码):负责组装 prompts、管理对话、驱动 Agent 执行
- MCP Client(协议客户端):负责发现工具、按协议发起调用、将结果转换为 ToolMessage
- MCP Server(工具提供方):提供一组可调用的工具(如 search、fetch、db query 等),可以运行在本地进程,也可以是远程 HTTP 服务
flowchart LR
LLM[LLM] <-->|tool_calls + ToolMessage| Host[LangChain Host
AgentExecutor]
Host --> Client[MCP Client
MultiServerMCPClient]
Client -->|streamable_http| Tavily[MCP Server: Tavily
Web Search Tools]
对于 Agent 而言,MCP server 暴露的工具与你自己编写的 @tool def grep(...) 没有本质区别:都是“可调用工具”。唯一差异在于工具的执行发生在远端。
6.2 配置 Tavily 的 MCP Client
以下代码将 Tavily MCP server 配置为一个远端工具源(transport 使用 streamable_http)。
import asyncio
import json
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"tavily": {
"transport": "streamable_http",
"url": "https://mcp.tavily.com/mcp/",
"headers": {
"Authorization": "Bearer tvly-dev-xxx",
"DEFAULT_PARAMETERS": json.dumps({
"include_favicon": True,
"include_images": False,
"include_raw_content": False,
}),
},
}
})
tavily_tools = asyncio.run(client.get_tools())
6.3 配置 Tavily 搜索 Agent
为 Tavily 单独配置一个搜索 Agent,使其职责单一:仅负责“联网查找资料并返回结果”。
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_classic.agents import create_tool_calling_agent, AgentExecutor
tavily_search_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个网络搜索助手,使用 Tavily 搜索工具来查找信息。"),
("human", """请使用搜索工具查找以下问题的答案:
问题:{question}
请使用可用的搜索工具进行搜索,并返回搜索结果。"""),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
tavily_agent = create_tool_calling_agent(llm_gpt4o, tavily_tools, tavily_search_prompt)
tavily_agent_executor = AgentExecutor(agent=tavily_agent, tools=tavily_tools, verbose=True)
6.4 本地无结果时,回退到 Tavily 远程搜索
最常见的策略是“本地优先、联网兜底”:
- 首先执行本地检索链路(keyword → grep → read_lines)
- 如果本地未命中(例如 grep 全部返回 0 matches),则将原问题交给 Tavily 搜索
flowchart TD
%% 节点定义
Start([用户提问]) --> Search[提取关键词]
subgraph LocalSystem [本地检索链路]
Search --> Grep[文本检索 / grep]
Grep --> Decision{本地知识库
是否命中?}
end
%% 分支1:本地成功
Decision -->|是| LocalAns[基于本地证据生成回答]
%% 分支2:外部搜索
Decision -->|否| WebSearch[调用 Tavily MCP 搜索]
subgraph ExternalTool [外部工具调用]
WebSearch --> ToolCall["tool_call: tavily.search"]
ToolCall --> Clean[清洗搜索结果]
end
Clean --> WebAns[基于搜索结果生成回答]
%% 样式美化
style Decision fill:#fdf,stroke:#333,stroke-width:2px
style LocalSystem fill:#f0faff,stroke:#005cc5,stroke-dasharray: 5 5
style ExternalTool fill:#fffdf0,stroke:#d4a017,stroke-dasharray: 5 5
style Start fill:#e1f5fe
在代码层面,可以通过一个轻量的判断将两条链路连接起来:
question = "三体3中的三则童话故事中隐含了什么技术信息"
local_search_results = search_agent_executor.invoke({"keywords": keywords, "path": FILE_PATH})["output"]
#这里的判断用于模拟,实际可以改成LLM判断回答是否有效
use_web = "未找到匹配结果" in local_search_results or "files_matched': 0" in local_search_results
if use_web:
web = tavily_agent_executor.invoke({"question": question})["output"]
final = answer_chain.invoke({"question": question, "search_results": web})
else:
read_result = read_agent_executor.invoke({"lineNo": local_search_results, "path": FILE_PATH})
final = answer_chain.invoke({"question": question, "search_results": read_result})
这样你就获得了一个更贴近现实的 Agent 行为:有本地证据时优先引用本地,无证据时再联网搜索。下一节可以进一步将“是否命中”的判断做得更结构化(例如让 search agent 输出可解析的 JSON),使回退逻辑更加稳健。
运行结果
question = "三体3中的三则童话故事中隐含了什么技术信息"
final_answer = answer_question(question, FILE_PATH)
最终答案:
最终答案:
============================================================
在《三体3:死神永生》中,云天明讲述了三个童话故事:《王国的新画师》、《饕餮海》和《深水王子》。这些故事中隐含了许多技术信息和隐喻,主要包括:
1. **二维化**:在《王国的新画师》中,针眼画师可以把人或物“画进画里”,这被解读为二维化的隐喻。二维化是指将三维空间降维到二维空间的过程。
2. **光速和曲率驱动**:在《饕餮海》中,香皂被用来麻痹饕餮鱼,使船能够渡海,这被解读为曲率驱动技术的隐喻。曲率驱动是指通过改变空间曲率来推动飞船的技术。
3. **黑域和信息隔绝**:无故事王国和饕餮鱼被解读为黑域的隐喻,黑域是指一个与外界无法交换信息的区域。
4. **光速不变性**:故事中提到的“不符合透视原理”的现象被解读为光速不变性,意味着光速是一个不会变化的常量。
这些童话故事通过隐喻的方式传达了三体文明的先进技术信息,并在小说中起到了重要的线索作用。云天明通过这些故事试图将他从三体世界了解到的技术信息传递给地球人,同时避免被三体人发现。```