1. 本章目标 #

第 39、40 两章反复出现同一个结论:Deep Agents 的能力是以工具的形式给出去的。 第 39 章数清了工具的数量和说明书的长度,第 40 章看到了模型怎么用它们。

这一章开始逐个拆解官方那四组能力,第一组是执行环境里最基础的一件事:工具面到底怎么控制。

本章要解决的核心问题,正是第 40 章 §7.2 留下的那个尾巴——当时模型多做了一堆无用动作(该读文件却又查又写),我说「收窄工具面更可靠,第 41 章讲」。这一章就来兑现。

学完你应能:

前置依赖: 第 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 彻底消失了。

为什么这个坑很危险? 因为它会让文件系统「半瘫」而不是「全瘫」:

而且现象非常隐蔽——模型会写文件、会报告「已保存」,只有当它试图读回时才出问题,然后它会以为文件不存在,重新做一遍(第 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-adapters

5.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 上,不会波及别处。

这两种情况才用第二种:

别把第二种当成安全措施。 工具还在图里,只是模型看不见。真正的安全边界是 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. 练习 #

基础(跑通即可)

  1. 数清你自己的工具面:把 §2.2 那段探测代码拿去,接上你项目里真实的业务工具,看工具面变成几个、说明书多少字符。
  2. 验证同名覆盖:故意定义一个叫 write_file 的工具,然后让 agent「把资料写成 a.md 再读回来」。观察它在哪一步开始出问题,以及它自己会不会意识到。
  3. 做一个只读 agent:用 FilesystemMiddleware(tools=["ls", "read_file", "grep"]),然后让它「把内容写成文件」。看它没有 write_file 时会怎么回应。

进阶(要动手设计)

  1. 两段式流程:把 §6.7 那个 writer / reader 拆分真正跑起来(writer 写完把 files 传给 reader 的初始状态),验证 reader 在没有写工具的情况下也能完成汇总。
  2. 算清收窄的收益:对同一个多轮任务,分别用默认工具面和收窄后的工具面各跑一遍,用第 40 章 §6.3 那个 bill() 函数统计总 token,算出真实省了多少钱。
  3. 接一台真 MCP 服务器:找一台公开的 MCP 服务器(比如第 17 章 §7.1 用的 https://docs.langchain.com/mcp),接进 deep agent,观察工具面涨到多少,以及要不要收窄。
  4. 给模型做适配 profile:用 HarnessProfile 给某个模型注册一份 excluded_tools,再换另一个模型跑同样的任务,验证 profile 只对注册的那个 provider 生效。
  5. (扩展)验证 issue #3948:换成 Claude 模型(官方自带 harness profile 会往提示词写内容),设 excluded_tools={"write_file"},检查 SystemMessage 里还提不提 write_file。如果复现了,说明那个 issue 在你的版本仍然存在。

9. 本章小结 #

  1. 内置工具是九件套:ls、read_file、write_file、edit_file、delete、glob、grep、execute 归 FilesystemMiddleware,task 归 SubAgentMiddleware。这个归属决定了收窄时该用哪个办法。
  2. execute 只在沙箱后端可用,默认 StateBackend 下它被装进 ToolNode 却不递给模型——所以模型只看到 9 个而不是 10 个。内置工具面不是固定的,随后端条件变化。
  3. 「装上了什么」和「模型看到什么」是两份清单。这个区分是 §6 两种收窄办法的分界线。
  4. 默认工具说明书 11158 字符,每轮对话重发一遍。这是收窄要优化的对象。
  5. tools= 三种形态:普通函数(自动从签名和 docstring 推断 schema)、LangChain 工具(需要改名或自定义 schema 时用)、供应商工具字典(和模型强绑定)。三种可以混传。
  6. 最重要的规则:tools= 是「加」不是「换」。 官方原话是「追加性的——它永远不会移除某个内置工具」。传 1 个工具,模型看到 9 个。
  7. 同名工具会静默覆盖内置的:自定义一个 read_file,内置那个就彻底消失(ToolNode 里执行的也变成你的),不报错、不共存。
  8. 同名覆盖的危险在于「半瘫」:write_file 还是内置的、read_file 变成了你的,于是模型写进去的文件再也读不回来,而症状和「没配 checkpointer」几乎一样,很难排查。给工具起名加业务前缀就能避开。
  9. MCP 接入就是 tools=mcp_tools,第 17 章那一整套知识(传输、鉴权、会话、拦截器)全部照搬。
  10. MCP 接 deep agent 有三处不同:必须全链路 ainvoke;工具面是叠加的(9 + 2 = 11,接几台服务器很容易破 30);MCP 工具返回的是 content blocks 而非字符串。
  11. 实测 MCP + harness 配合得很好:3 次模型调用、8 秒、1 万 tokens,完成「并行查三次外部工单系统 → 汇总 → 写成 brief.md」,还自己补了一段要点提示。
  12. 收窄办法一 FilesystemMiddleware(tools=[...]) 是白名单、真移除:ToolNode 从 10 降到 5,说明书降 38%。但 read_file 是必需项,不给会在建 Agent 时就报 ValueError。
  13. 收窄办法二 HarnessProfile(excluded_tools=...) 是黑名单、只挡视线:ToolNode 一个没少(还是 10),模型看到的从 9 降到 6。它不是 create_deep_agent 的参数,要用 register_harness_profile 注册。
  14. 两者怎么选:默认用第一种(写在调用处、范围可控);要处理 task 或业务工具、或者要按模型做适配时用第二种。都不要当成安全措施——真正的边界是 permissions=(第 43 章)。
  15. 去掉 task 用 GeneralPurposeSubagentProfile(enabled=False) 并且不传 subagents=,这样 SubAgentMiddleware 压根不挂,ToolNode 从 10 降到 9。
  16. 两种手段叠加,说明书从 11158 压到 4900 字符,降 56%,模型看到的工具从 9 个降到 4 个。这个收益每轮对话都能拿到。
  17. profile 是全局累加的:同 key 重复注册取并集而不是替换,也没有通配 key。测多组配置要分进程,否则会拿到累加后的结果。
  18. 第 40 章那个「多做无用动作」的问题,结构性解法就是把写的能力拿掉——工具面里没有的工具,模型再怎么想也调不出来。

下一章讲虚拟文件系统与后端:ls / read_file / write_file / edit_file / delete / glob / grep 各自的行为细节,四种后端(StateBackend 线程内、FilesystemBackend 真落盘、StoreBackend 跨线程、CompositeBackend 按路径分流)怎么选——第 40 章 §5.3 那个「文件不在你硬盘上」的疑问,以及本章 execute 为什么不可用,都会在那里得到完整解释。