上一章我们写了一个最小 Agent,把“模型会在需要时调用工具”这件事跑通了。这一章继续往下挖:Tool 到底怎么定义、怎么被模型调用、以及如何用一组 Tool 搭一个最小的“知识库问答”。
1. 目标:用 Tools 写一个最小知识库问答
今天的目标很明确:
- 让你对 Agent 里的 Tools 有一个“可运行”的直觉:模型如何选择工具、如何传参、如何读取工具返回值
- 用一份本地文本当“知识库”,做一个最小的 QA:问题 → 抽关键词 → 在文件里检索 → 读出证据 → 生成答案
这里的“知识库”非常朴素:就是一个文本文件,不做向量化,不做 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 会把它当“可解析的环境反馈”来消费,而不是当纯文本
[!TIP] 为什么 Coding Agent 要自己准备 grep/read/glob,而不是直接用系统命令?
你可能会想:
grep不就是系统命令吗?为什么不直接让模型跑grep?更常见的做法是:把“系统能力”封装成受控工具,原因主要有四个:
- 可控与安全:限制访问范围、参数类型、最大输出长度;系统命令一旦放开,风险面会非常大
- 可移植:不同系统的命令行行为、编码、路径、权限差异很大;工具把差异屏蔽掉,链路更稳定
- 结构化返回:模型更擅长消费 JSON/dict 结果;命令行输出往往需要再解析一遍
- 可观测与可调试:工具输入输出可记录、可回放、可统计;调试 Agent 时这点非常关键
所以很多 Coding Agent(包括 Claude Code / OpenAI 的一些 Agent 形态)都会内置或自动生成
read_file / grep / glob / edit_file这类基础工具:本质上是“把 OS 能力包装成给模型用的 API”。
4. 代码:四个 Agent 串成一条检索链(keyword → search → read → answer)
注意:此处我们模拟了一个可用的workflow来演示chain,但是实际根据Agent的定义,应该让Agent自身去进行plan-execution,这个在下一章会介绍到。
这个例子把一个最小的“文本知识库问答”拆成 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(固定剧本的 workflow)
这部分就是经典的 workflow:每一步都由代码编排好,模型只在每一步内部“决定怎么调用工具”。
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: 生成答案 ===
最终答案: 云天明买了一颗星星送给暗恋着的大学同学程心。
补充:每个 chain 的结果分析(基于真实输出)
下面用流程图把你实际跑出来的链路画出来(示例问题:云天明买了什么送给谁)。
flowchart TD
Q["用户问题<br/>云天明买了什么送给谁"]
K["keyword agent<br/>输出关键词 JSON list<br/>[云天明, 买, 送, 谁]"]
Q --> K
S["search agent<br/>逐个关键词检索行号"]
K --> S
G1["grep(云天明)<br/>命中: 第 17 行"]
G2["grep(买)<br/>命中: 第 17 行"]
G3["grep(送)<br/>命中: 第 17 行"]
G4["grep(谁)<br/>未命中: 0"]
S --> G1
S --> G2
S --> G3
S --> G4
L["聚合 line_numbers<br/>[17]"]
G1 --> L
G2 --> L
G3 --> L
G4 --> L
H["注意:search agent 的摘要<br/>可能是模型生成<br/>不等于原文证据"]
S -.-> H
R["read agent<br/>按行号取证据"]
L --> R
E["ground truth 证据<br/>第 17 行原文<br/>云天明买下一颗星星送给程心"]
R --> E
A["answer chain<br/>基于问题+证据生成答案"]
E --> A
OUT["最终答案<br/>云天明买了一颗星星送给暗恋的大学同学程心"]
A --> OUT
图里每个节点对应你日志里的一个阶段:
- keyword agent 负责把问题拆成可检索的关键词(含实体与动作)
- search agent 通过多次
grep把关键词映射到证据行号(“谁”未命中不影响后续) - read agent 用
read_lines取回原文作为 ground truth(纠正任何摘要幻觉) - answer chain 只做“基于证据的改写”,不给它工具、也不让它凭空编
5. 拆解:LLM Tool Call 的真实过程(基于 Prompt Messages)
我们接下来深入探索一下LLM Tool的内部原理,我们对上面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 在表达什么?
在日志里,messages 大致是这样(按角色看):
- SystemMessage:定义角色——“你是一个文件搜索助手”
- HumanMessage:定义任务——“对每个关键词分别调用 grep,找行号”
- AIMessage(空 Content):这是“发起工具调用”的那一轮模型输出
- ToolMessage ×4:框架执行了 4 次
grep,把每次返回的 dict 作为 ToolMessage 塞回历史 - AIMessage(有 Content):第二轮模型输出,把四次 grep 的结果整理成“搜索结果如下…”
5.2 ToolMessage 的作用:把外部世界塞回上下文
在 search agent 这一步,grep 工具返回的是结构化 dict:
pattern:本次检索的关键词results[].line_numbers:命中的行号数组total_matches / files_matched:统计信息
LangChain 把这些 dict 作为 ToolMessage 放回 messages,让模型在第二轮可以“逐条读取”并做聚合。
你这次的结果非常干净:
"云天明" / "买" / "送"都命中第 17 行"谁"没命中
所以第二轮模型很自然会生成一个“按关键词列出行号”的汇总。
Tool Call ID解析 上面的调用实际进行了三次
grep的调用,每次都有单独的tool_call_id,而结果返回给LLM的时候也需要带上对应的tool_call_id。
5.4 用图把整个 Tool Call 过程画出来(含对应消息)
sequenceDiagram
participant U as HumanMessage<br/>(任务描述)
participant AE as AgentExecutor<br/>(编排器)
participant L as LLM<br/>(tool-calling)
participant G as Tool: grep
Note over AE: messages[1]=SystemMessage<br/>messages[2]=HumanMessage
AE->>L: 第一次 LLM 调用<br/>输入: System+Human
Note over L: 输出: AIMessage(Content 可能为空)<br/>并携带 tool_calls:<br/>grep("云天明") / grep("买") / grep("送") / grep("谁")
L-->>AE: tool_calls 列表
AE->>G: 调用 grep(pattern="云天明", path=...)
G-->>AE: ToolMessage #1<br/>{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="买", path=...)
G-->>AE: ToolMessage #2<br/>{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="送", path=...)
G-->>AE: ToolMessage #3<br/>{line_numbers:[17], ...}
AE->>G: 调用 grep(pattern="谁", path=...)
G-->>AE: ToolMessage #4<br/>{results:[], ...}
AE->>L: 第二次 LLM 调用<br/>输入: System+Human+ToolMessages
Note over L: 输出: AIMessage(自然语言汇总)<br/>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<br/>AgentExecutor]
Host --> Client[MCP Client<br/>MultiServerMCPClient]
Client -->|streamable_http| Tavily[MCP Server: Tavily<br/>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())
[!WARNING]
Authorization不要写死在代码仓库里。更稳妥的方式是把 token 放环境变量,在运行时注入。
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{本地知识库<br/>是否命中?}
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. **光速不变性**:故事中提到的“不符合透视原理”的现象被解读为光速不变性,意味着光速是一个不会变化的常量。
这些童话故事通过隐喻的方式传达了三体文明的先进技术信息,并在小说中起到了重要的线索作用。云天明通过这些故事试图将他从三体世界了解到的技术信息传递给地球人,同时避免被三体人发现。