1. 本章目标 #
第 39、40 两章反复出现同一个结论:Deep Agents 的能力是以工具的形式给出去的。 第 39 章数清了工具的数量和说明书的长度,第 40 章看到了模型怎么用它们。
这一章开始逐个拆解官方那四组能力,第一组是执行环境里最基础的一件事:工具面到底怎么控制。
本章要解决的核心问题,正是第 40 章 §7.2 留下的那个尾巴——当时模型多做了一堆无用动作(该读文件却又查又写),我说「收窄工具面更可靠,第 41 章讲」。这一章就来兑现。
学完你应能:
- 说清内置九件套各管什么,以及
execute为什么在你的机器上「不存在」 - 用三种形态往
tools=里传东西:普通函数、LangChain 工具、供应商工具字典 - 理解一条关键规则:
tools=是「加」不是「换」,它永远删不掉内置工具 - 避开一个静默的坑:自定义工具和内置工具同名会直接覆盖内置的(实测)
- 把 MCP 服务器的工具接进 deep agent,并说清和第 17 章接
create_agent有什么不同 - 用两种层次不同的办法收窄工具面,并分清「彻底移除」和「只挡住模型视线」
- 把工具说明书从 11158 字符压到 4900 字符(实测降 56%)
前置依赖: 第 39 章(工具面这个概念)、第 40 章(跑得起来)、第 8 章(@tool 与 schema)、第 17 章(MCP,本章 §5 直接建立在它上面,没读过的建议先补)。
参考文档:
建议阅读顺序: §2 先盘清家底。§4 是本章最重要的一节,那条「加不是换」的规则和同名覆盖的坑,不知道会踩。§5 是 MCP,读过第 17 章的可以只看 §5.3。§6 是本章的产出,收窄工具面的两种办法都在这里。
本章验证环境:deepagents 0.7.12、langchain-mcp-adapters 0.3.2、mcp 1.29.1,模型 deepseek-v4-flash。本章大部分实验是零 token 的(用假模型截获工具面),只有 §5.2 那次 MCP 实跑会真实花钱,约 1 万 tokens。
2. 先看清你已经有什么 #
在往 tools= 里加东西之前,得先知道白送的是哪些。
2.1. 内置九件套 #
官方 Tools 页给了这张表,这是最权威的一份清单:
| 工具 | 官方说明 | 归哪个 middleware 管 |
|---|---|---|
ls |
列出目录里的文件 | FilesystemMiddleware |
read_file |
读文件内容(支持分页与多模态) | FilesystemMiddleware |
write_file |
新建文件,或覆盖已有文件 | FilesystemMiddleware |
edit_file |
在文件里做精确字符串替换 | FilesystemMiddleware |
delete |
删文件,或递归删目录(需要 deepagents>=0.7) |
FilesystemMiddleware |
glob |
按通配符找文件 | FilesystemMiddleware |
grep |
搜文件内容 | FilesystemMiddleware |
execute |
跑 shell 命令(仅沙箱后端) | FilesystemMiddleware |
task |
派子智能体处理委派的任务 | SubAgentMiddleware |
前八个都归 FilesystemMiddleware,只有 task 归 SubAgentMiddleware。这个归属关系在 §6 收窄工具面时很关键——两类工具要用两种不同的办法处理。
另外,write_todos(任务规划)不在这张表里。第 39 章 §6 实测过,deepagents>=0.7 起它改成了按需开启,要显式传 TodoListMiddleware(第 47 章讲)。
2.2. execute 为什么「不存在」 #
第 39 章 §3.3 有个发现:ToolNode 里注册了 10 个工具,但模型只看到 9 个,差的那个是 execute。第 40 章 §3 又从另一个角度印证了一次——模型自我介绍时主动说「我没有执行系统命令的能力」。
原因就在官方表格那句括号里:execute 仅沙箱后端可用。 默认的 StateBackend 是个纯内存的虚拟文件系统,压根没有能跑命令的地方,所以这个工具虽然装上了,却不会递给模型。
这引出一个值得记住的概念:内置工具面不是固定的九个,而是随后端条件变化的。 换成沙箱后端(第 43 章),execute 就会出现在模型的工具清单里。
用一段零 token 的代码把这件事验证清楚。这段代码本章会反复用到,因为它是观察工具面最直接的手段:
# 这段代码不花一分钱:用假模型截获工具面后就返回,不真的推理
from dotenv import load_dotenv
# GenericFakeChatModel 是 LangChain 自带的假模型,用来做本地探测
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
# 我们要偷梁换柱的真实模型类
from langchain_deepseek import ChatDeepSeek
from langchain.tools import tool
from deepagents import create_deep_agent
load_dotenv(override=True)
MODEL = "deepseek:deepseek-v4-flash"
# 用来存放截获到的东西
CAP = {}
class Fake(GenericFakeChatModel):
"""假模型:被调用时只记录收到的消息,然后回一句 ok。"""
def _generate(self, messages, stop=None, run_manager=None, **kw):
# 顺手记下模型收到的完整消息列表(§6.4 会用到)
CAP["msgs"] = messages
return super()._generate(messages, stop, run_manager, **kw)
def spy_bind(self, tools, **kw):
"""替换掉真模型的 bind_tools:记下工具清单,然后返回假模型。"""
CAP["names"] = [
getattr(t, "name", None) or (t.get("name") if isinstance(t, dict) else "?")
for t in tools
]
CAP["objs"] = list(tools)
# 关键:返回假模型,所以后面 invoke 时不会真的请求 API
return Fake(messages=iter([AIMessage(content="ok")] * 80))
# 打补丁。这样用 "deepseek:..." 字符串建 Agent 时,profile 等机制照常生效,
# 但真正推理的是假模型,一分钱不花
ChatDeepSeek.bind_tools = spy_bind
def surface(**kwargs):
"""建一个 deep agent,返回(ToolNode 里注册的工具, 模型实际看到的工具)。"""
CAP.clear()
agent = create_deep_agent(model=MODEL, **kwargs)
# ToolNode 里注册了哪些工具:这是「装上了什么」
node = agent.nodes["tools"]
reg = []
for holder in (node, getattr(node, "bound", None)):
for attr in ("tools_by_name", "_tools_by_name"):
obj = getattr(holder, attr, None) if holder is not None else None
if obj:
reg = sorted(obj.keys())
break
if reg:
break
# 跑一次,触发 bind_tools,才能拿到「模型看到了什么」
try:
agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
except Exception:
# 假模型的回答不符合流程要求时可能抛错,这里不关心
pass
return reg, sorted(CAP.get("names", []))
# 一个业务工具,后面各节都复用它
@tool
def get_ticket(ticket_id: str) -> str:
"""按工单号查询工单状态。工单号形如 T-1001。"""
return "ok"
reg, seen = surface(tools=[get_ticket])
print(f"ToolNode 注册 {len(reg)} 个: {reg}")
print(f"模型看到 {len(seen)} 个: {seen}")
print("注册了但没给模型:", sorted(set(reg) - set(seen)))运行输出:
ToolNode 注册 10 个: ['delete', 'edit_file', 'execute', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
模型看到 9 个: ['delete', 'edit_file', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
注册了但没给模型: ['execute']「装上了什么」和「模型看到什么」是两个不同的清单。 这个区分现在看着像细节,但它是 §6 两种收窄办法的分界线,请先记住。
2.3. 这些工具要花多少钱 #
工具面不是白送的,它按 token 计费。给上面那段代码接一个统计:
import json
def surface_chars(objs):
"""把工具的名字、描述、参数 schema 的字符数全加起来。"""
total = 0
for o in objs:
total += len(getattr(o, "name", "") or "")
total += len(getattr(o, "description", "") or "")
try:
# 参数 schema 也要发给模型,同样计费
total += len(json.dumps(o.args_schema.model_json_schema(), ensure_ascii=False))
except Exception:
pass
return total
# 接 §2.2 的 surface() 调用之后
print(f"工具说明书合计 {surface_chars(CAP['objs'])} 字符")运行输出:
工具说明书合计 11158 字符1.1 万字符,每一轮对话都要重发一遍。 第 39 章算过这笔账(当时是 11058 字符,差的 100 字符是本章多加的 get_ticket),第 40 章实测出简单问答的输入 token 涨了 7.4 倍。
这个数字就是 §6 要压缩的对象。 先记住基线:11158 字符 / 9 个可见工具。
3. tools= 的三种形态 #
家底盘清了,开始往里加东西。官方原话是:
把任何可调用对象——普通函数、
@tool装饰过的函数、或者工具字典——直接传给tools=。Deep Agents 会从函数签名和 docstring 推断出工具 schema,所以大多数情况下你不需要另外定义 schema。
三种形态可以混着传。逐个看。
3.1. 形态一:普通函数 #
连 @tool 都不用加。 第 40 章 §4.2 那个 Tavily 例子就是这么写的:
from typing import Literal
from deepagents import create_deep_agent
# 注意:这就是个普通函数,没有任何装饰器
def refund_estimate(order_id: str, reason: Literal["质量", "物流", "其他"] = "其他") -> str:
"""估算订单可退金额。reason 影响是否扣除运费。"""
# docstring 会成为工具说明,类型标注会成为参数 schema
base = 199.0
freight = 0.0 if reason == "质量" else 12.0
return f"订单 {order_id} 可退 {base - freight:.2f} 元(运费扣除 {freight:.2f})"
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
tools=[refund_estimate],
)
print("已接入:", [t for t in ["refund_estimate"]])Deep Agents 会自动包装它。三样东西被自动提取:
| 你写的 | 变成模型看到的 |
|---|---|
函数名 refund_estimate |
工具名 |
| docstring | 工具说明 |
类型标注(含 Literal 的取值约束) |
参数 schema |
所以 docstring 不是注释,是发给模型的产品说明书。 第 8 章强调过这一点,这里同样适用——写得含糊,模型就会用错。
3.2. 形态二:LangChain 工具 #
加了 @tool 的,或者 StructuredTool、自定义 BaseTool 子类,都能直接传:
from langchain.tools import tool
from deepagents import create_deep_agent
@tool
def get_ticket(ticket_id: str) -> str:
"""按工单号查询工单状态。工单号形如 T-1001。"""
return f"工单 {ticket_id}:处理中"
agent = create_deep_agent(
model="deepseek:deepseek-v4-flash",
tools=[get_ticket],
)什么时候值得加 @tool? 当你需要它带来的额外能力时:
| 需求 | 写法 |
|---|---|
| 工具名和函数名不一样 | @tool("query_ticket") |
| 参数说明要写得更细 | 用 args_schema 传 Pydantic 模型 |
要拿到 ToolRuntime(state / store / 上下文) |
@tool 配合 ToolRuntime 参数 |
| 要控制出错时怎么回灌模型 | handle_tool_errors 等参数 |
只是包一个普通函数的话,两种写法效果一样,@tool 可以省。
3.3. 形态三:供应商工具字典 #
第 40 章 §4.1 见过这种:传字典,不传函数,工具在供应商那边执行:
from deepagents import create_deep_agent
# 这类工具的写法各家不同,都是字典
google_search = {"google_search": {}}
openai_search = {"type": "web_search"}
anthropic_search = {"type": "web_search_20260209", "name": "web_search"}
agent = create_deep_agent(
model="google_genai:gemini-3.6-flash",
# 字典和函数可以混在同一个列表里
tools=[google_search, refund_estimate, get_ticket],
)这类工具和模型强绑定:{"google_search": {}} 只在配 Google 模型时有效,换 DeepSeek 会报错。本章的实验用 DeepSeek,所以这一形态只讲不跑——你要用的话,务必确认模型和工具是同一家的。
3.4. 混传时的一个提醒 #
三种形态混在一个列表里完全合法:
tools=[
google_search, # 供应商字典
refund_estimate, # 普通函数
get_ticket, # @tool 装饰的
*mcp_tools, # MCP 拉来的(§5)
]但要注意:混传时报错信息可能很难读。 字典形态写错了 key,报错往往指向模型初始化而不是你的工具列表。所以加工具时一次加一个、加完跑一次,比一口气加十个再排查要省时间。
4. 最重要的一条规则:tools= 是「加」不是「换」 #
这一节是本章的核心。官方 API 参考对 tools= 参数有一句措辞很硬的说明:
这些工具会和上面列出的内置工具套件(文件系统工具、
execute、task)合并。在这里传工具是追加性的——它永远不会移除某个内置工具。
4.1. 「加不是换」意味着什么 #
很多人第一次用会以为 tools=[my_tool] 是「我只要这一个工具」。实测一下就知道不是:
# 复用 §2.2 的 surface() 和 get_ticket
reg, seen = surface(tools=[get_ticket])
print(f"我只传了 1 个工具,模型却看到 {len(seen)} 个:")
print(seen)运行输出:
我只传了 1 个工具,模型却看到 9 个:
['delete', 'edit_file', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']传 1 个,得 9 个。 你传进去的那个只是第 9 个。
这个设计是合理的——harness 的价值就在那 8 个内置工具上,如果 tools= 会覆盖它们,那 create_deep_agent 就退化成 create_agent 了。但它带来一个直接后果:想减少工具,tools= 这个参数帮不上你,得用 §6 的办法。
4.2. 坑:同名会静默覆盖 #
既然是「合并」,那如果我的工具和内置工具同名会怎样?
这个问题很实际。read_file、write_file、delete、grep、task 都是很普通的名字,业务代码里撞上一个不奇怪。
实测(依然零 token):
# 复用 §2.2 的 surface()
from langchain.tools import tool
# 先看对照组
reg0, seen0 = surface(tools=[get_ticket])
print(f"对照组(不同名)模型看到 {len(seen0)} 个: {seen0}")
# 现在故意起一个和内置工具同名的
@tool
def read_file(file_path: str) -> str:
"""我自己的 read_file:专门读工单附件,不读虚拟文件系统。"""
return "mine"
reg1, seen1 = surface(tools=[read_file])
print(f"\n冲突组模型看到 {len(seen1)} 个: {seen1}")
# 检查有没有出现两个同名工具
from collections import Counter
dup = {k: v for k, v in Counter(CAP["names"]).items() if v > 1}
print("是否重名:", dup or "无重名")
# 关键:留下来的那个 read_file 是谁的?
for o in CAP["objs"]:
if getattr(o, "name", "") == "read_file":
print("模型看到的 read_file 描述:", (o.description or "")[:60])运行输出:
对照组(不同名)模型看到 9 个: ['delete', 'edit_file', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
冲突组模型看到 8 个: ['delete', 'edit_file', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
是否重名: 无重名
模型看到的 read_file 描述: 我自己的 read_file:专门读工单附件,不读虚拟文件系统。注意三件事:
一、没有报错。 完全静默。
二、没有出现两个 read_file。 工具总数从 9 变成 8(因为我的工具占了内置那个的位置,而不是新增一个)。
三、留下来的是我的那个。 描述已经变成「我自己的 read_file」——内置的 read_file 被顶掉了。
再确认一下真正执行的是哪个(ToolNode 里那份):
# 复用 §2.2 的 MODEL
from deepagents import create_deep_agent
agent = create_deep_agent(model=MODEL, tools=[read_file])
node = agent.nodes["tools"]
for holder in (node, getattr(node, "bound", None)):
for attr in ("tools_by_name", "_tools_by_name"):
obj = getattr(holder, attr, None) if holder is not None else None
if obj and "read_file" in obj:
print("ToolNode 里实际执行的 read_file:", (obj["read_file"].description or "")[:60])
break运行输出:
ToolNode 里实际执行的 read_file: 我自己的 read_file:专门读工单附件,不读虚拟文件系统。执行的也是我的。内置 read_file 彻底消失了。
为什么这个坑很危险? 因为它会让文件系统「半瘫」而不是「全瘫」:
write_file还是内置的,写进虚拟文件系统read_file变成了你的,读的是工单附件- 于是模型写进去的文件,自己再也读不回来
而且现象非常隐蔽——模型会写文件、会报告「已保存」,只有当它试图读回时才出问题,然后它会以为文件不存在,重新做一遍(第 40 章 §7.1 那个「没配 checkpointer」的症状,和这个几乎一模一样)。
所以给工具起名时,先避开这九个保留名。 一个简单可靠的办法是加业务前缀:
| 危险 | 安全 |
|---|---|
read_file |
ticket_read_attachment |
delete |
ticket_close |
grep |
kb_search |
task |
ticket_assign |
前缀还有个附带好处:模型不容易把业务工具和文件工具搞混。 第 17 章 §12 讲多 MCP 服务器时用过同样的思路(工具名加服务器前缀),道理是一样的。
5. 接入 MCP 服务器 #
第 17 章用整整一章讲了 MCP:怎么写服务器、stdio 和 HTTP 两种传输、会话管理、拦截器、多服务器前缀。那些知识在这里全部适用,不重复。
这一节只讲一件事:MCP 工具接进 deep agent,和第 17 章接 create_agent 有什么不一样。
5.1. 接法:就是 tools= #
官方的说法很直接:从任意 MCP 服务器加载工具,然后直接传给 create_deep_agent。
装包(第 17 章 §3 装过就不用了):
uv add langchain-mcp-adapters5.2. 实测:把工单 MCP 接进 deep agent #
这个项目的 backend/app/mcp_servers/ticket_server.py 本来就是一台 MCP 服务器(第 17 章用过),它提供两个工具:get_ticket 和 search_tickets。直接拿来用。
这次是真实调用,会花钱(约 1 万 tokens)。
import asyncio
import os
import sys
import time
from dotenv import load_dotenv
# 第 17 章那个客户端,一字不差
from langchain_mcp_adapters.client import MultiServerMCPClient
from deepagents import create_deep_agent
load_dotenv(override=True)
MODEL = "deepseek:deepseek-v4-flash"
# 用绝对路径,避免 stdio 子进程的工作目录问题
SERVER = os.path.abspath("backend/app/mcp_servers/ticket_server.py")
async def main():
# stdio 传输:客户端把服务器当子进程拉起来
client = MultiServerMCPClient({
"ticket": {
"transport": "stdio",
# 用 sys.executable 保证子进程用的是同一个虚拟环境的 python
"command": sys.executable,
"args": [SERVER, "--transport", "stdio"],
}
})
# 拉工具。这一步会真的启动子进程、握手、列出工具
mcp_tools = await client.get_tools()
print(f"拉到 {len(mcp_tools)} 个 MCP 工具")
for t in mcp_tools:
print(f" {t.name}: {t.description[:50]}")
# 关键一步:MCP 工具就是普通的 LangChain 工具,直接进 tools=
agent = create_deep_agent(
model=MODEL,
tools=mcp_tools,
system_prompt="你是工单运营助手。工单数据一律通过 MCP 工具查询,不要凭记忆编造。",
)
task = ("查一下工单 T-1001 和 T-1003 的状态,再搜一下所有『待分配』的工单,"
"把结果整理成一份简报存成 brief.md。")
t0 = time.perf_counter()
# 注意用 ainvoke,原因见 §5.3
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": task}]},
config={"configurable": {"thread_id": "mcp-1"}},
)
print(f"\n运行耗时 {time.perf_counter() - t0:.1f}s")
print("\n=== 消息轨迹 ===")
for i, m in enumerate(result["messages"]):
kind = type(m).__name__
tcs = getattr(m, "tool_calls", None) or []
if tcs:
for c in tcs:
# 把关心的几个参数值挑出来显示
arg = (c["args"].get("ticket_id") or c["args"].get("status")
or c["args"].get("file_path") or "")
print(f"{i:2d} {kind:12s} -> {c['name']} {arg}")
else:
txt = m.content if isinstance(m.content, str) else str(m.content)
print(f"{i:2d} {kind:12s} {len(txt):5d}字 | {txt[:55]}")
files = result.get("files") or {}
for p, v in files.items():
body = v.get("content", "") if isinstance(v, dict) else v
print(f"\n=== {p}({len(body)} 字符)===")
print(body[:400])
asyncio.run(main())运行输出:
拉到 2 个 MCP 工具
get_ticket: 按工单号查询外部工单系统状态。工单号形如 T-1001。
search_tickets: 在外部工单系统中按关键词或状态搜索。status 可选:处理中/已关闭/待分配。
运行耗时 8.0s
=== 消息轨迹 ===
0 HumanMessage 63字 | 查一下工单 T-1001 和 T-1003 的状态,再搜一下所有『待分配』的工单...
1 AIMessage -> get_ticket T-1001
1 AIMessage -> get_ticket T-1003
1 AIMessage -> search_tickets 待分配
2 ToolMessage 120字 | [{'type': 'text', 'text': '工单 T-1001:状态=处理中,类别=物流...
3 ToolMessage 120字 | [{'type': 'text', 'text': '工单 T-1003:状态=待分配,类别=咨询...
4 ToolMessage 110字 | [{'type': 'text', 'text': '找到 1 条:\n- T-1003 [待分配] 保修期如何计算...
5 AIMessage -> write_file brief.md
6 ToolMessage 22字 | Updated file /brief.md
7 AIMessage 272字 | 简报已整理并保存为 `brief.md`,内容如下:...
=== /brief.md(339 字符)===
# 工单运营简报
查询时间:即时查询(工单数据来自外部工单系统)
## 一、指定工单状态
### T-1001
- 状态:处理中
- 类别:物流
- 摘要:快递延误两日
- 处理人:客服小李
### T-1003
- 状态:待分配
- 类别:咨询
- 摘要:保修期如何计算
- 处理人:未分配
## 二、『待分配』工单汇总
共找到 **1** 条:
| 工单号 | 状态 | 摘要 |
| ------ | ---- | ---- |
| T-1003 | 待分配 | 保修期如何计算 |
## 三、要点提示
1. T-1003 目前处于「待分配」状态,且无处理人,建议尽快安排分配,避免积压。
2. T-1001 正在处理中,由客服小李跟进,无需额外介入。只用了 3 次模型调用、8 秒、10668 输入 tokens,就完成了「查三次外部系统 → 汇总 → 落盘」。轨迹干净得可以当范例:第 1 步一条消息并行发出三个 MCP 调用,第 5 步写文件,第 7 步汇报。
这就是大纲里说的产出「业务工具包接入 deep agent」:MCP 提供业务数据的入口,harness 提供文件系统来承载产出,两边各管一段。注意最后那份简报里的「要点提示」——那是模型自己加的分析,不在任务要求里。
5.3. 和第 17 章的三个不同 #
同样是接 MCP,接给 deep agent 有三处需要留意。
一、必须用 ainvoke。
这一条第 17 章 §5.2 已经解释过原因:MCP 适配器底层是异步的。放在这里的意义是——deep agent 的同步 invoke 也一样不能用,harness 并不会帮你桥接。所以整条链路都得是异步的:await client.get_tools()、await agent.ainvoke(...)。
二、工具面是叠加的,而且涨得比你想象的快。
# 接 §5.2 的 agent
node = agent.nodes["tools"]
for holder in (node, getattr(node, "bound", None)):
for attr in ("tools_by_name", "_tools_by_name"):
obj = getattr(holder, attr, None) if holder is not None else None
if obj:
print(f"工具面共 {len(obj)} 个: {sorted(obj.keys())}")
break运行输出:
工具面共 11 个: ['delete', 'edit_file', 'execute', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'search_tickets', 'task', 'write_file']9 个内置 + 2 个 MCP = 11 个。 两个 MCP 工具听起来不多,但真实的 MCP 服务器动辄提供十几二十个工具——接三台服务器,工具面破 50 很容易。而第 40 章 §2.2 那张 eval 表已经说明:工具一多,模型挑错工具的概率就上升(gpt-5.4 的工具使用只有 18%)。
所以接 MCP 时,第 17 章 §12 那个「只拉需要的工具」的建议在 deep agent 上更重要,因为你的起点已经是 9 个了,不是 0 个。
三、MCP 工具的返回值是 content blocks,不是字符串。
看上面轨迹里第 2~4 条 ToolMessage:
[{'type': 'text', 'text': '工单 T-1001:状态=处理中...它是一个列表,里面是带 type 的字典,而不是纯字符串(对比第 5 步内置 write_file 返回的 Updated file /brief.md 就是纯字符串)。这是 MCP 适配器的标准行为,为了支持图片、音频等多模态返回。
平时不用管——模型能正常理解。但你自己写代码解析 ToolMessage.content 时要留意,直接当字符串处理会拿到一坨 [{'type': ...}]。要取纯文本用 m.text 或者遍历 m.content_blocks(第 17 章 §10 讲过)。
本章不重复的部分: HTTP 传输、请求头鉴权、
client.session()持久会话、拦截器、多服务器工具名前缀、Resources 与 Prompts——这些在 deep agent 里的用法和第 17 章完全一致,直接照搬即可。
6. 收窄工具面 #
现在来解决欠了一章的问题。
6.1. 为什么要收窄 #
第 40 章 §7.2 那个实验里,任务只是「读一下 vec.md」,模型却做了这一串:
search_docs ×3 → ls ×2 → glob → write_file → read_file ×2只有最后的 read_file 是必要的。当时我给了两个办法,并说第二种更可靠:
| 办法 | 性质 |
|---|---|
| 提示词里写「直接读,别重新检索」 | 概率性约束 |
干脆不给它 write_file |
结构性约束 |
第 19 章 §3.3 论证过这两者的区别:提示词约束要你「证明它不会出错」,结构约束只要「证明那条路不存在」。工具面里没有的工具,模型再怎么想也调不出来。
除了这个,收窄还有两个好处:
省钱。 §2.3 那 11158 字符每轮都要重发。
降低挑错工具的概率。 第 40 章 §2.2 那张 eval 表里,gpt-5.4 栽的就是这一项。
6.2. 办法一:FilesystemMiddleware(tools=[...])——彻底移除 #
官方 API 参考给的第一个办法:
要彻底移除某个内置工具,传一个你自己的
FilesystemMiddleware,并指定tools=[...]。
写法是把 FilesystemMiddleware 自己实例化一遍,用 tools= 给出白名单。关键在于它和 create_deep_agent(tools=...) 是两个完全不同的参数——前者是白名单(只装这些),后者是追加(额外加这些)。
能填哪些名字?deepagents 导出了一个类型可以直接查:
from deepagents import FsToolName
# 这是个 Literal 类型,__args__ 里就是全部合法取值
print(FsToolName.__args__)运行输出:
('ls', 'read_file', 'write_file', 'edit_file', 'delete', 'glob', 'grep', 'execute')八个,注意没有 task。 因为 task 归 SubAgentMiddleware 管(§2.1 那张表),得用另一个办法处理(§6.5)。
现在做一个只读的 agent——只留 ls、read_file、grep:
# 复用 §2.2 的 surface()、surface_chars() 和 get_ticket
from deepagents import FilesystemMiddleware
reg2, seen2 = surface(
tools=[get_ticket],
# 白名单:只装这三个文件工具
middleware=[FilesystemMiddleware(tools=["ls", "read_file", "grep"])],
)
print(f"ToolNode 注册 {len(reg2)} 个: {reg2}")
print(f"模型看到 {len(seen2)} 个: {seen2}")
print("消失的工具:", sorted(set(seen) - set(seen2)))
print(f"工具说明书 {surface_chars(CAP['objs'])} 字符")运行输出:
ToolNode 注册 5 个: ['get_ticket', 'grep', 'ls', 'read_file', 'task']
模型看到 5 个: ['get_ticket', 'grep', 'ls', 'read_file', 'task']
消失的工具: ['delete', 'edit_file', 'glob', 'write_file']
工具说明书 6889 字符注意 ToolNode 也从 10 降到了 5。 这是「彻底移除」的证据——那些工具根本没被装进去,不是藏起来了。顺带一提,execute 也消失了(它不在白名单里)。
说明书从 11158 降到 6889 字符,省了 4269 字符,降 38%。
这里有个硬约束要知道:read_file 不能不给。 实测三种组合:
from deepagents import FilesystemMiddleware
for combo in ([], ["ls"], ["read_file"]):
try:
FilesystemMiddleware(tools=combo)
print(f" tools={combo} -> 通过")
except Exception as e:
print(f" tools={combo} -> {type(e).__name__}: {e}")运行输出:
tools=[] -> ValueError: read_file must be included in tools; it is required by FilesystemMiddleware
tools=['ls'] -> ValueError: read_file must be included in tools; it is required by FilesystemMiddleware
tools=['read_file'] -> 通过read_file 是 FilesystemMiddleware 的必需项,想「一个文件工具都不给」是做不到的。这个限制很合理:harness 的上下文卸载机制(把大结果写成文件、需要时读回)离了 read_file 就断了。
好在这个报错在建 Agent 时就抛,不是运行到一半才出问题,属于第 40 章 §10 分类里的「会明确报错」,比静默失效友好得多。
6.3. 办法二:HarnessProfile 的 excluded_tools——只挡视线 #
官方给的第二个办法,措辞和第一个不一样:
要不再把某个内置工具提供给模型,注册一个带
excluded_tools的HarnessProfile。
「不再提供给模型」和「彻底移除」——这个用词差别是有意的,下面会看到实测差别。
先说一个容易走错的地方:excluded_tools 不是 create_deep_agent 的参数。 打印一下签名就清楚了:
import inspect
from deepagents import create_deep_agent
print(list(inspect.signature(create_deep_agent).parameters))运行输出:
['model', 'tools', 'system_prompt', 'middleware', 'subagents', 'skills', 'memory',
'permissions', 'backend', 'interrupt_on', 'response_format', 'state_schema',
'context_schema', 'checkpointer', 'store', 'debug', 'name', 'cache']没有 excluded_tools。 它是 HarnessProfile 上的字段,要先注册 profile,create_deep_agent 在构造模型之后会自动应用:
# 复用 §2.2 的 surface() 和 get_ticket
from deepagents import HarnessProfile, register_harness_profile
# 先记录基线
reg0, seen0 = surface(tools=[get_ticket])
print(f"基线:ToolNode {len(reg0)} 个,模型看到 {len(seen0)} 个")
# 按 provider 注册("deepseek" 表示这家所有模型都适用)
# 也可以按具体模型注册,键写成 "deepseek:deepseek-v4-flash"
register_harness_profile(
"deepseek",
HarnessProfile(excluded_tools={"write_file", "edit_file", "delete"}),
)
reg1, seen1 = surface(tools=[get_ticket])
print(f"\n排除三个写类工具之后:")
print(f" ToolNode 注册 {len(reg1)} 个: {reg1}")
print(f" 模型看到 {len(seen1)} 个: {seen1}")
print(f" ToolNode 少了: {sorted(set(reg0) - set(reg1)) or '(没变)'}")
print(f" 模型少了: {sorted(set(seen0) - set(seen1))}")运行输出:
基线:ToolNode 10 个,模型看到 9 个
排除三个写类工具之后:
ToolNode 注册 10 个: ['delete', 'edit_file', 'execute', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
模型看到 6 个: ['get_ticket', 'glob', 'grep', 'ls', 'read_file', 'task']
ToolNode 少了: (没变)
模型少了: ['delete', 'edit_file', 'write_file']关键差别出来了:ToolNode 一个没少,还是 10 个;模型看到的从 9 个降到 6 个。
这就是 §2.2 那个「装上了什么 vs 模型看到什么」的区分派上用场的地方。excluded_tools 的实现方式是在工具注入之后加一道过滤,只拦住递给模型的那一份清单,工具本身仍然装在图里。
两个使用注意:
一、profile 是按 key 全局注册的,而且重复注册会累加。 官方明确说明「在已有 key 上重新注册会把新 profile 合并到旧的上面——不是替换」,excluded_tools 的合并规则是取并集。所以在一个脚本里测多组配置时,前面注册的会影响后面——本章这几组实验是分开的进程跑的,你自己试的时候要注意这一点,否则会得到累加后的结果而不知道为什么。
二、没有通配 key。 想对所有模型生效,得给每个用到的 provider 各注册一遍。官方的建议是:依赖模型的调整才用 profile,与模型无关的全局调整应该直接写在 create_deep_agent 调用处。
一个官方已知问题: GitHub 上有个 issue #3948 报告说
excluded_tools能去掉工具 schema,但系统提示词里仍然会提到那些工具,原因是过滤中间件排在FilesystemMiddleware之后,而后者无条件往提示词里写了内容。不过在本章这个配置下(DeepSeek + 不带 skills / memory)复现不出来——实测
SystemMessage长度为 0,剩余工具的描述里也没有提到被排除的那三个。这和第 39 章 §3.4 的发现一致:默认配置的系统提示词本来就是空的。所以这个问题你可能遇不到,但如果换成 Claude(官方自带 profile 会写提示词)或者用了 skills,就要留意检查。
6.4. 两种办法怎么选 #
把实测结果并排放:
FilesystemMiddleware(tools=[...]) |
HarnessProfile(excluded_tools=...) |
|
|---|---|---|
| 性质 | 白名单:只装这些 | 黑名单:装了但不给模型看 |
ToolNode 里 |
真的少了(10 → 5) | 一个没少(还是 10) |
| 模型看到 | 少了(9 → 5) | 少了(9 → 6) |
| 作用范围 | 这一个 agent | 按 provider / 模型全局生效 |
能处理 task 吗 |
不能(只管文件工具) | 能(按名字过滤,不限来源) |
| 能处理业务工具吗 | 不能 | 能(官方说它「也能丢掉用户传入的工具」) |
| 硬限制 | read_file 必须留 |
无 |
| 写在哪 | create_deep_agent 调用处 |
全局注册,调用处看不见 |
实践建议:
默认用第一种。 它就写在调用处,读代码的人一眼能看到工具面被收窄了,不会困惑。作用范围也限定在这个 agent 上,不会波及别处。
这两种情况才用第二种:
- 要去掉的是
task或者某个业务工具(第一种管不着) - 要针对某个模型做适配(比如「这个模型老是误用
grep,那就对它禁掉」)——这正是 profile 这个机制的设计意图
别把第二种当成安全措施。 工具还在图里,只是模型看不见。真正的安全边界是 permissions=(第 43 章),它管的是「即使模型调了,也不许执行」。
6.5. 去掉 task #
task 不归 FilesystemMiddleware 管,所以白名单办法对它无效。官方给的正规做法是禁用 general-purpose 子智能体:
要让 agent 不带
task工具运行……设置general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),并且不要通过subagents=传任何同步子智能体。SubAgentMiddleware(以及task工具)只在至少存在一个同步子智能体时才会挂上,所以这个配置能干净地把它排除掉。
# 复用 §2.2 的 surface() 和 get_ticket
from deepagents import (
GeneralPurposeSubagentProfile,
HarnessProfile,
register_harness_profile,
)
register_harness_profile(
"deepseek",
# 把默认的 general-purpose 子智能体关掉
HarnessProfile(general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False)),
)
# 注意:这里没有传 subagents=,否则 SubAgentMiddleware 又会挂上
reg4, seen4 = surface(tools=[get_ticket])
print(f"ToolNode 注册 {len(reg4)} 个: {reg4}")
print(f"模型看到 {len(seen4)} 个: {seen4}")
print("task 还在吗:", "在" if "task" in seen4 else "已移除")运行输出:
ToolNode 注册 9 个: ['delete', 'edit_file', 'execute', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'write_file']
模型看到 8 个: ['delete', 'edit_file', 'get_ticket', 'glob', 'grep', 'ls', 'read_file', 'write_file']
task 还在吗: 已移除ToolNode 从 10 降到 9,task 彻底没了——这次是真的移除(因为整个 SubAgentMiddleware 都没挂上),不是隐藏。
什么时候该去掉 task? 第 40 章 §6.3 算过账:派子智能体贵 11 倍。如果你的场景就是「单一任务、上下文装得下」,那 task 只是个诱惑——去掉它,省下说明书的 token,也省掉模型误判「这任务好复杂我得派个人」的风险。
6.6. 叠加起来:说明书降 56% #
把两种手段一起上——只留读类文件工具 + 无 task:
# 承接 §6.5 已注册的 profile(general-purpose 已禁用)
from deepagents import FilesystemMiddleware
reg5, seen5 = surface(
tools=[get_ticket],
middleware=[FilesystemMiddleware(tools=["ls", "read_file", "grep"])],
)
print(f"模型看到 {len(seen5)} 个: {seen5}")
print(f"工具说明书 {surface_chars(CAP['objs'])} 字符")运行输出:
模型看到 4 个: ['get_ticket', 'grep', 'ls', 'read_file']
工具说明书 4900 字符完整的对比:
| 配置 | 模型看到 | 说明书字符 | 相比基线 |
|---|---|---|---|
| 默认(只加一个业务工具) | 9 个 | 11158 | — |
| 只留读类文件工具 | 5 个 | 6889 | 降 38% |
读类文件工具 + 去掉 task |
4 个 | 4900 | 降 56% |
说明书砍掉一半多。 而这 4900 字符里还包含你自己的业务工具,纯 harness 的开销压得更狠。
这个收益是每轮对话都能拿到的——因为工具面每次请求都要重发。对话轮次越多、任务越长,省得越多。
6.7. 回到第 40 章那个问题 #
现在可以正式回答了。第 40 章 §7.2 里模型「该读文件却又查又写」,根本原因是它手里有 write_file,而任务描述又有点含糊,于是它选择了「重新查一遍再写一遍」这条更保险的路。
结构性的解法就是把写的能力拿掉:
from dotenv import load_dotenv
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from deepagents import FilesystemMiddleware, create_deep_agent
load_dotenv(override=True)
@tool
def search_docs(topic: str) -> str:
"""按主题检索企业内部资料库。可用主题:向量库、切分策略、重排。"""
data = {
"向量库": "【向量库评审】Chroma 单机嵌入式 P95 18ms。FAISS 最强 P95 3ms 但只是库不是服务。pgvector 和业务库同源,运维最省 P95 45ms。",
"切分策略": "【切分策略】递归字符切分是默认选择,中文技术文档召回率高 12%。语义切分效果最好但 10 万字约 3 元。",
"重排": "【重排复盘】召回 50 条再精排 5 条,Top-5 命中率 71%→89%,延迟 +300ms。",
}
for k, v in data.items():
if k in topic:
return v
return f"没有找到「{topic}」。可用主题:{', '.join(data)}"
# 阶段一:负责写。给全套文件工具
writer = create_deep_agent(
model="deepseek:deepseek-v4-flash",
tools=[search_docs],
checkpointer=InMemorySaver(),
)
# 阶段二:负责读。结构上就没有写文件的能力
reader = create_deep_agent(
model="deepseek:deepseek-v4-flash",
# 连 search_docs 都不给,逼它只能从文件里找答案
tools=[],
middleware=[FilesystemMiddleware(tools=["ls", "read_file", "grep"])],
checkpointer=InMemorySaver(),
)
print("writer 能写文件;reader 只能读")
print("reader 的工具面里没有 write_file / edit_file / delete,模型无论怎么想都调不出来")这就是「结构性约束」的样子:不需要在提示词里反复叮嘱,因为那条路根本不存在。代价是你得把流程拆成两段,多写几行代码——可靠性和便利性之间的取舍,第 19 章那一章讲的就是这个。
7. 实用约定与坑 #
| 约定 | 说明 |
|---|---|
| 加工具前先数一遍现有工具面 | 起点是 9 个不是 0 个(§2.2) |
| 给工具起名避开九个保留名 | 加业务前缀,如 ticket_read_attachment(§4.2) |
普通函数就够用,不必都加 @tool |
需要改名 / 自定义 schema / 拿 runtime 时才加(§3.2) |
| 供应商工具字典要和模型同一家 | 换供应商就得换写法(§3.3) |
| MCP 全链路用异步 | await get_tools() + await ainvoke()(§5.3) |
| 接 MCP 时只拉需要的工具 | 起点已经 9 个了,很容易破 30(§5.3) |
收窄工具面默认用 FilesystemMiddleware(tools=[...]) |
写在调用处,看得见(§6.4) |
read_file 必须留 |
上下文卸载机制依赖它(§6.2) |
| 一个脚本里测多个 profile 要分进程 | 同 key 重复注册会取并集累加(§6.3) |
别把 excluded_tools 当安全措施 |
工具还在图里,安全边界是 permissions=(第 43 章) |
一、静默失效(不报错,但结果不对)
| 现象 | 原因 | 处理 |
|---|---|---|
| 模型写了文件却读不回来 | 自定义工具和 read_file 同名,顶掉了内置的 |
改名(§4.2) |
传了 tools=[x] 但工具面还是 9 个多 |
tools= 是追加,不是替换 |
用 §6 的办法收窄 |
excluded_tools 设了却好像没生效 |
注册的 key 和实际模型不匹配 | 检查 provider 名或改用 provider:model 全名(§6.3) |
| 收窄后模型开始瞎编内容 | 把它需要的工具也砍了 | 回看轨迹,确认它缺哪个(§6.2) |
解析 ToolMessage.content 得到一坨 [{'type':...}] |
MCP 工具返回的是 content blocks | 用 m.text 或遍历 content_blocks(§5.3) |
| profile 的排除范围越试越大 | 同 key 重复注册会累加取并集 | 分进程测(§6.3) |
二、会明确报错
| 现象 | 原因 | 处理 |
|---|---|---|
ValueError: read_file must be included in tools |
白名单里漏了 read_file |
补上(§6.2) |
TypeError: unexpected keyword argument 'excluded_tools' |
它不是 create_deep_agent 的参数 |
改用 HarnessProfile(§6.3) |
把 FilesystemMiddleware 写进 excluded_middleware 时报 ValueError |
它是 harness 的必需骨架,不许整体移除 | 改用 excluded_tools 或白名单(§6.4) |
| MCP 相关调用报「不能在同步上下文里执行」 | 忘了用 ainvoke |
全链路改异步(§5.3) |
| 供应商工具字典报错 | 工具和模型不是同一家 | 换工具或换模型(§3.3) |
三、概念性误解
| 误解 | 纠正 |
|---|---|
「tools= 是我这个 agent 的完整工具清单」 |
它是追加,永远删不掉内置工具(§4.1) |
| 「同名工具会共存,或者会报错」 | 会静默覆盖内置的(§4.2) |
「excluded_tools 把工具删了」 |
只挡住模型视线,工具还在 ToolNode 里(§6.3) |
「FilesystemMiddleware(tools=[]) 能一个不留」 |
read_file 是必需项,会报错(§6.2) |
「FsToolName 里应该有 task」 |
task 归 SubAgentMiddleware,得用别的办法(§6.5) |
| 「MCP 接 deep agent 要重新学一套」 | 和第 17 章完全一样,只有三处差异(§5.3) |
8. 练习 #
基础(跑通即可)
- 数清你自己的工具面:把 §2.2 那段探测代码拿去,接上你项目里真实的业务工具,看工具面变成几个、说明书多少字符。
- 验证同名覆盖:故意定义一个叫
write_file的工具,然后让 agent「把资料写成 a.md 再读回来」。观察它在哪一步开始出问题,以及它自己会不会意识到。 - 做一个只读 agent:用
FilesystemMiddleware(tools=["ls", "read_file", "grep"]),然后让它「把内容写成文件」。看它没有write_file时会怎么回应。
进阶(要动手设计)
- 两段式流程:把 §6.7 那个 writer / reader 拆分真正跑起来(writer 写完把
files传给 reader 的初始状态),验证 reader 在没有写工具的情况下也能完成汇总。 - 算清收窄的收益:对同一个多轮任务,分别用默认工具面和收窄后的工具面各跑一遍,用第 40 章 §6.3 那个
bill()函数统计总 token,算出真实省了多少钱。 - 接一台真 MCP 服务器:找一台公开的 MCP 服务器(比如第 17 章 §7.1 用的
https://docs.langchain.com/mcp),接进 deep agent,观察工具面涨到多少,以及要不要收窄。 - 给模型做适配 profile:用
HarnessProfile给某个模型注册一份excluded_tools,再换另一个模型跑同样的任务,验证 profile 只对注册的那个 provider 生效。 - (扩展)验证 issue #3948:换成 Claude 模型(官方自带 harness profile 会往提示词写内容),设
excluded_tools={"write_file"},检查SystemMessage里还提不提write_file。如果复现了,说明那个 issue 在你的版本仍然存在。
9. 本章小结 #
- 内置工具是九件套:
ls、read_file、write_file、edit_file、delete、glob、grep、execute归FilesystemMiddleware,task归SubAgentMiddleware。这个归属决定了收窄时该用哪个办法。 execute只在沙箱后端可用,默认StateBackend下它被装进ToolNode却不递给模型——所以模型只看到 9 个而不是 10 个。内置工具面不是固定的,随后端条件变化。- 「装上了什么」和「模型看到什么」是两份清单。这个区分是 §6 两种收窄办法的分界线。
- 默认工具说明书 11158 字符,每轮对话重发一遍。这是收窄要优化的对象。
tools=三种形态:普通函数(自动从签名和 docstring 推断 schema)、LangChain 工具(需要改名或自定义 schema 时用)、供应商工具字典(和模型强绑定)。三种可以混传。- 最重要的规则:
tools=是「加」不是「换」。 官方原话是「追加性的——它永远不会移除某个内置工具」。传 1 个工具,模型看到 9 个。 - 同名工具会静默覆盖内置的:自定义一个
read_file,内置那个就彻底消失(ToolNode里执行的也变成你的),不报错、不共存。 - 同名覆盖的危险在于「半瘫」:
write_file还是内置的、read_file变成了你的,于是模型写进去的文件再也读不回来,而症状和「没配 checkpointer」几乎一样,很难排查。给工具起名加业务前缀就能避开。 - MCP 接入就是
tools=mcp_tools,第 17 章那一整套知识(传输、鉴权、会话、拦截器)全部照搬。 - MCP 接 deep agent 有三处不同:必须全链路
ainvoke;工具面是叠加的(9 + 2 = 11,接几台服务器很容易破 30);MCP 工具返回的是 content blocks 而非字符串。 - 实测 MCP + harness 配合得很好:3 次模型调用、8 秒、1 万 tokens,完成「并行查三次外部工单系统 → 汇总 → 写成 brief.md」,还自己补了一段要点提示。
- 收窄办法一
FilesystemMiddleware(tools=[...])是白名单、真移除:ToolNode从 10 降到 5,说明书降 38%。但read_file是必需项,不给会在建 Agent 时就报ValueError。 - 收窄办法二
HarnessProfile(excluded_tools=...)是黑名单、只挡视线:ToolNode一个没少(还是 10),模型看到的从 9 降到 6。它不是create_deep_agent的参数,要用register_harness_profile注册。 - 两者怎么选:默认用第一种(写在调用处、范围可控);要处理
task或业务工具、或者要按模型做适配时用第二种。都不要当成安全措施——真正的边界是permissions=(第 43 章)。 - 去掉
task用GeneralPurposeSubagentProfile(enabled=False)并且不传subagents=,这样SubAgentMiddleware压根不挂,ToolNode从 10 降到 9。 - 两种手段叠加,说明书从 11158 压到 4900 字符,降 56%,模型看到的工具从 9 个降到 4 个。这个收益每轮对话都能拿到。
- profile 是全局累加的:同 key 重复注册取并集而不是替换,也没有通配 key。测多组配置要分进程,否则会拿到累加后的结果。
- 第 40 章那个「多做无用动作」的问题,结构性解法就是把写的能力拿掉——工具面里没有的工具,模型再怎么想也调不出来。
下一章讲虚拟文件系统与后端:ls / read_file / write_file / edit_file / delete / glob / grep 各自的行为细节,四种后端(StateBackend 线程内、FilesystemBackend 真落盘、StoreBackend 跨线程、CompositeBackend 按路径分流)怎么选——第 40 章 §5.3 那个「文件不在你硬盘上」的疑问,以及本章 execute 为什么不可用,都会在那里得到完整解释。