工具体系:发现、执行、审批与持久化日志
30 秒导读: 第 2 章讲了「LLM 说要调工具 → 循环怎么把它跑起来」。本章往下钻一层,讲工具本身怎么被接到 agent 上并安全执行:一个工具要满足什么契约、系统怎么把一目录的工具文件自动发现出来、LLM 给的函数名怎么映射回真正的工具、要不要人工审批、执行结果怎么截断入库,以及最关键的——每一次工具调用怎么被记进一张对账日志表,让"提议了什么 / 真跑了没有 / 成功还是失败"事后都查得到。
本章不重复第 2 章的「工具循环调度」(LLMHandler 如何驱动多轮),只讲这一次调用从"被发现"到"落库"的纵向切面。
1. 这是什么(零基础也能懂)
一句话定义
工具(Tool)= 给 LLM 的一只手。 模型本身只会生成文字;要它真去搜文档、调 HTTP API、连远程 MCP 服务器、在沙箱里跑一段 Python,就得把这些能力包装成"工具",告诉模型"你可以调用这些函数",再由后端替模型真的去执行。
解决什么问题
假设你在 DocsGPT 里问:"把这个仓库里所有 .py 文件的行数统计成一张表。" 模型不能凭空数文件,它需要:
- 先搜知识库找到文件列表(
internal_search工具); - 再在沙箱里跑一段统计代码(
code_executor工具); - 可能还要调你自己配的外部 API(
api_tool)或连一台 MCP 服务器(mcp_tool)。
工具体系要回答的问题是:这些五花八门的能力,怎么用同一套机制挂上去、被模型看见、被安全地执行,并且留下可对账的痕迹?
一句话直觉
把工具体系想成一家餐厅的后厨传菜系统:
- 菜谱(契 约):每道菜必须写清"叫什么、要什么料、怎么做"——对应
Tool的三个方法。 - 点菜台(发现):开店时自动把后厨所有菜谱扫一遍贴到菜单上——对应
ToolManager的pkgutil扫描。 - 划单员(执行器):顾客(LLM)点的菜名要翻译成后厨编号、决定要不要经理签字(审批)、把菜做出来、并在流水账本上记一笔——对应
ToolExecutor与tool_call_attempts日志。
2. 顶层全景(它大概怎么转)
一次工具调用,数据流是这样走的(从左到右,自上而下):
┌──────────────────────────────────────┐
开店/建请求时 │ ① 发现 (ToolManager) │
───────────────► │ pkgutil 扫 tools/ 目录,把每个 Tool │
│ 子类实例化进 self.tools 字典 │
└───────────────┬──────────────────────┘
│ 可用工具集 tools_dict
▼
每一轮 LLM 调用前 ┌──────────────────────────────────────┐
───────────────► │ ② 准备 (prepare_tools_for_llm) │
│ 工具动作 → LLM 函数 schema; │
│ 重名消歧 + 截到 64 字符 + 建反向名称映射 │
└───────────────┬──────────────────────┘
│ 函数列表交给模型
▼
模型 回了 tool_call ┌──────────────────────────────────────┐
───────────────► │ ③ 解析 (ToolActionParser) │
│ 函数名 → (tool_id, action_name) + 参数 │
└───────────────┬──────────────────────┘
▼
┌──────────────────────────────────────┐
│ ④ 审批 (check_pause) │
│ client_side? require_approval? │
│ headless? → 暂停 / 拒绝 / 放行 │
└───────────────┬──────────────────────┘
│ 放行
▼
┌──────────────────────────────────────┐
│ ⑤ 执行 (execute) │
│ _record_proposed → 加载工具 │
│ → tool.execute_action → _mark_executed │
│ 截断结果 / 判定状态 / 落库 │
└──────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
Tool(ABC) | 定义所有工具必须实现的三方法契约 + internal 标志 | application/agents/tools/base.py |
ToolManager | pkgutil 动态发现工具模块;按需实例化(部分工具带 user_id 作用域) | application/agents/tools/tool_manager.py |
ToolExecutor | 准备 schema、名称映射、审批、执行、写对账日志 | application/agents/tool_executor.py |
ToolActionParser | 把 LLM 的函数调用解析回 (tool_id, action_name, 参数) | application/agents/tools/tool_action_parser.py |
ToolCallAttemptsRepository | 对账日志表的 proposed/executed/failed 三种写法 | application/storage/db/repositories/tool_call_attempts.py |
default_tools | 合成"默认开"和"agent 可选内置"工具的伪 user_tools 行 | application/agents/default_tools.py |
3. 工具契约:一个工具要长什么样
3.1 三个方法 + 一个标志
所有工具都继承 Tool 这个抽象基类。它只要求实现三件事——application/agents/tools/base.py:4 class Tool(ABC):
# 示意,非源码 —— 真实定义见 base.py
class Tool(ABC):
internal: bool = False # True = 不出现在"添加工具"目录里
def execute_action(self, action_name, **kwargs): ... # 真正干活
def get_actions_metadata(self): ... # 我有哪些动作、参数长啥样
def get_config_requirements(self): ... # 我需要哪些配置(如 API Key)
三方法各自的职责,可以对上"传菜系统"的三个动作:
| 方法 | 回答的问题 | 谁来消费它 |
|---|---|---|
get_actions_metadata() | 我能做哪几件事?每件事要什么参数? | prepare_tools_for_llm 拿去转成给模型的函数 schema |
execute_action(action_name, **kwargs) | 给定动作名和参数,真去执行 | ToolExecutor.execute 在放行后调用 |
get_config_requirements() | 我需要用户填哪些配置(URL / 密钥…) | 前端渲染配置表单;启动校验默认工具时用 |
internal 标志的作用点很关键:一个 internal = True 的工具不会被 ToolManager.load_tools 自动挂到用户菜单(见下一节的过滤条件),但仍可被显式加载。典型例子是 InternalSearchTool——它是把 RAG 检索包成工具的"合成工具",不该出现在用户的"添加工具"目录里。
3.2 两类"动作"来源
不是所有工具的动作都写死在代码里。DocsGPT 里动作元数据有两种来源:
- 静态:代码里
get_actions_metadata()直接返回,如internal_search、code_executor。 - 动态:运行时从外部拉取。
mcp_tool连上 MCP 服务器后list_tools,把远端工具翻译成本地动作(mcp_tool.py:542get_actions_metadata);api_tool干脆返回空[](api_tool.py:231),因为它的动作完全由用户在配置里定义。
4. 动态发现:一个目录变成一份菜单
4.1 pkgutil 扫描
ToolManager 一被构造就调 load_tools(),用标准库 pkgutil.iter_modules 遍历 tools/ 目录下的每个模块——tool_manager.py:15 load_tools:
# 示意,非源码 —— 真实逻辑见 tool_manager.py:15
for finder, name, ispkg in pkgutil.iter_modules([tools_dir]):
if name == "base" or name.startswith("__"):
continue # 跳过基类和 dunder
module = importlib.import_module(f"...tools.{name}")
for member_name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Tool) and obj is not Tool and not obj.internal:
self.tools[name] = obj(self.config.get(name, {})) # 实例化
重点看那行 if:一个类要被自动挂上,必须同时满足——是 Tool 的子类、不是 Tool 本身、且 internal 为假。这就是 §3.1 里 internal 标志的落点:合成工具(如 internal_search)被这一步主动排除在自动菜单外。
新增一个工具,不用改任何注册表——往 tools/ 目录扔一个文件、定义一个 Tool 子类,下次构造 ToolManager 时自动就在了。这就是"约定优于配置"。
4.2 哪些工具需要 user_id 作用域
有些工具的行为依赖调用者是谁:它要读某个用户的笔记、待办、连他授权过的 MCP 服务器、在他的会话沙箱里跑代码。这类工具不能在"开店时"一次性实例化——那时还不知道请求属于谁。
ToolManager 用一个硬编码集合区分它们。load_tool(单个按需加载)和 execute_action 里都有同一份名单——tool_manager.py:26 load_tool:
# 示意,非源码 —— 名单见 tool_manager.py:31-46
USER_SCOPED = {
"mcp_tool", "notes", "memory", "todo_list", "scheduler",
"remote_device", "code_executor", "artifact_generator", "read_document",
}
if tool_name in USER_SCOPED and user_id:
return obj(tool_config, user_id) # 多传一个 user_id
else:
return obj(tool_config) # 普通工具只吃 config
坑点(inferred): 这份名单在 load_tool 和 execute_action 里各写了一份、且和 ToolExecutor._get_or_load_tool 的加载路径并行存在。名单一旦有工具漏加,那个工具就会丢掉 user 上下文(拿 None 作 user_id),表现为"读不到该用户的数据"。改这里要三处一起核对。
5. 名称映射:LLM 看到的名字 ≠ 内部标识
5.1 为什么需要映射
模型那边只认一个扁平的函数名(如 search),但后端要知道这属于哪个工具实例的哪个动作——即 (tool_id, action_name)。而且两个不同工具可能有同名动作(brave 和 duckduckgo 都有 search),OpenAI 的函数名还有 ^[a-zA-Z0-9_-]{1,64}$ 的限制。
prepare_tools_for_llm 就负责把 tools_dict 里每个动作转成一条 LLM 函数 schema,同时解决重名消歧和64 字符截断——tool_executor.py:391 prepare_tools_for_llm。它的策略:
| 情况 | LLM 看到的名字 |
|---|---|
| 动作名唯一且 ≤ 64 字符 | 原样(get_weather) |
| 动作名重复 | 加工具名前缀消歧(brave_search / duckduckgo_search) |
| 仍冲突 | 追加数字后缀(..._1),且不能偷走某个唯一动作已占的名字 |
| 超长 | 截到 64 字符 |
5.2 反向映射表:抗脆弱的关键
转换时,执行器建立一张反向映射 _name_to_tool: {llm_name → (tool_id, action_name)}——tool_executor.py:453。这样模型回调时,直接查表就能定位,不靠字符串切割。
早期实现是"按 _ 切分函数名反推 tool_id"的脆弱做法,如今只作为 legacy 回退保留——tool_action_parser.py:34:
# 示意,非源码 —— 见 _parse_openai_llm, tool_action_parser.py:26
resolved = self._resolve_via_mapping(call.name) # 优先查反向映射表
if resolved:
return resolved[0], resolved[1], call_args # (tool_id, action_name, args)
# 查不到才退回按 "_" 切割的老逻辑(并告警可能是幻 觉调用)
ToolActionParser 按 LLM 家族分派解析器(OpenAILLM / GoogleLLM)。它还兜了几个健壮性坑:
- Google 的 resume 路径会把本是 dict 的
arguments字符串化,解析器要json.loads还原,失败则退回空 dict,避免下游.items()崩流——tool_action_parser.py:57_parse_google_llm。 - 函数名切不出 ≥2 段、或 tool_id 不是数字,都打 warning 提示"可能是模型幻觉出来的工具调用"。
6. 审批与暂停:执行前的那道闸
check_pause 在真正执行前跑,决定这次调用是直接放行、暂停等人批、还是在无人值守模式下拒绝——tool_executor.py:486 check_pause。它返回一个 pending-action 字典(或 None 表示放行)。
6.1 三条岔路
check_pause
│
┌──────────────┼─────────────────┐
▼ ▼ ▼
client_side? require_approval? 都不是
│ │ │
客户端工具 需要人工批准 放行
(浏览器端跑) │ (return None)
│ ┌─────┴─────┐
headless? 交互模式 headless 模式
│ 暂停等批准 查 tool_allowlist
拒绝 (awaiting_ 命中且非强制 → 放行
(denied) approval) 否则 → headless_denied
为什么 headless(无人值守)要单独处理? 定时任务、webhook 触发的 run 没有人能点"批准"。所以 check_pause 在 self.headless 为真时,把本该暂停的调用改成返回 pause_type: "headless_denied" 的哨兵,上游循环据此合成一个错误结果喂回模型,而不是卡死等一个永远不会来的批准。
6.2 审批决策不信缓存,直接问工具
user_tools.actions[].require_approval 是一份快照,可能已经过时。对两类高危工具,check_pause 跳过快照、当场实例化工具去问:
remote_device:调preview_decision,按设备的实时approval_mode、allow/denylist、sticky 模式判定;出任何错就默认强制弹批准,绝不静默放行——tool_executor.py:612_remote_device_requires_approval。code_executor:部署级config.require_approval是权威,盖过快照;出错fail-closed(要求批准),绝不静默跑未审代码——tool_executor.py:640_code_executor_requires_approval。
一个安全细节(denylist_forced): headless 模式下,预授权的 tool_allowlist 可以让"普通需批准"的调用放行;但如果批准是硬性 denylist 强制的(denylist_forced = True),allowlist 不得绕过——tool_executor.py:575。即"这台设备被加白名单"不等于"denylist 里的命令也能跑"。
7. 执行:从提议到落库
execute 是主干——tool_executor.py:667。它是个 generator:一路 yield 状态事件(pending / error / 最终结果),最后 return (result, call_id)。核心顺序:
① 解析 call → (tool_id, action_name, call_args)
② tool_id 不合法 / 找不到? → 记 proposed + mark_failed,产 error 事件,提前返回
③ _record_proposed(...) ← 先写"提议"日志,拿到 proposed_ok
④ call_args 不是 dict? → mark_failed,防 .items() 崩流
⑤ 按 action 元数据分拣参数到 query_params/headers/body/parameters
⑥ _get_or_load_tool(...) ← 加载工具(带缓存 + 解密凭据)
⑦ tool.execute_action(...) ← 真正执行;异常则 mark_failed 后 re-raise
⑧ 抽取 artifact_id(如有)
⑨ 截断结果 + 判定 status + _mark_executed(...) ← 回填"已执行"日志
⑩ yield 最终事件(剔除 result_full 等重字段)
7.1 结果的两种"瘦身"
工具原始结果可能很大(一段代码输出、一个 API 的大 JSON)。执行器对它做两件事:
- 截断入库/推流:
truncate_tool_result把持久化到 message、推给前端 UI 的副本截到PERSISTED_RESULT_MAX_LEN = 2000字符——tool_executor.py:48。注意注释里的教训:早期截到 50 字符,把所有真实报错都藏在...后面,导致"重试风暴"无从诊断。但 LLM 和对账日志始终拿完整结果,截断只作用于 message 的 JSONB 副本。 - 状态判定:
result_status从结果里读出真实状态——tool_executor.py:56。工具用**带内(in-band)**方式报错:返回{"status": "error", ...}或带error键。执行器据此把日志状态标成error而非一律completed,否则"存档里失败的调用看起来都成功了"。
7.2 凭据按"工具所有者"解密(委托授权)
_get_or_load_tool 加载工具时,如果配置里有 encrypted_credentials,解密用的不是调用者的 sub,而是工具行的 user_id(所有者)——tool_executor.py:978。这样团队成员跑一个共享工具时,用的是所有者的凭据(有意的委托授权),并且会打一条 tool_credential_delegation 审计日志。
工具实例还带缓存:cache_key = f"{name}:{tool_id}:{user}"。命中缓存时会刷新 attachments,避免上一轮的附件残留到这一轮——tool_executor.py:944。api_tool 因为 config 随 action 变,不缓存。
8. 持久化日志:tool_call_attempts 对账机制
这是本章最"精华"的一块。DocsGPT 为每一次工具调用在 tool_call_attempts 表里留痕,状态机是:
proposed ──成功──► executed / confirmed
│
└──失败──► failed
(proposed 写失败但工具照跑了 → upsert 直接补一行 executed)
三个执行器级函数各管一段——都在 tool_executor.py:
| 函数 | 时机 | 做什么 |
|---|---|---|
_record_proposed | 执行前 | 插 proposed 行;返回 True 当且仅当这次调用真的建了行 |
_mark_executed | 执行后 | 翻成 executed;若 proposed 曾失败,upsert 补一行 |
_mark_failed | 出错时 | 翻成 failed,写异常文本 |
8.1 为什么按 message_id 命名空间去重
tool_call_attempts.call_id 是全表主键,但 LLM 会跨轮、跨用户复用确定性 id(如 functions.create_artifact:0)。若直接拿 call_id 当键,不同的逻辑调用会在主键上撞车,后来的日志行被 ON CONFLICT DO NOTHING 静默丢弃。
解法是 _journal_key——tool_executor.py:84:
# 示意,非源码 —— 见 _journal_key, tool_executor.py:84
def _journal_key(call_id, message_id):
return f"{message_id}:{call_id}" if message_id else call_id
- 有 message_id(每轮唯一):用
message_id:call_id作键,让每个逻辑调用有自己的行;而同一轮内真正的重试(同 call_id、同 message_id)仍能去重。 - 无 message_id(headless):保留裸
call_id,维持既有行为。 - 原始
call_id不动:仍用于 LLM 的 tool_call/tool_result 配对和 UI。
8.2 ON CONFLICT DO NOTHING 的坑与守卫
record_proposed 用 ON CONFLICT (call_id) DO NOTHING 防止重复 id 抛 IntegrityError——tool_call_attempts.py:51。但这带来一个必须处理的语义:插入返回 rowcount == 0(即 proposed_ok = False)时,那行可能属于另一个还在飞的请求。所以 _record_proposed 的文档明确警告:此时调用方不得再去 _mark_failed / _mark_executed 翻它——否则会篡改别人的行。执行器里凡是后续的 _mark_failed 都包在 if proposed_ok: 里(如 tool_executor.py:789)。
三个 repo 方法都加了同样的守卫,值得记住这套模式:
mark_executed/mark_failed的 UPDATE 都带WHERE ... status = 'proposed'——只翻还处于 proposed 的行,绝不把已终态(executed/confirmed/failed)或别的租户的行改掉——tool_call_attempts.py:170、tool_call_attempts.py:190。- 给了
user_id时,再加AND user_id IS NOT DISTINCT FROM :user_id,让跨租户撞 id 也伤不到这行。 upsert_executed的ON CONFLICT DO UPDATE同样只升级"仍是 proposed 且同 user"的行——tool_call_attempts.py:110。
一句话精华: 因为主键
call_id被 LLM 复用,这张表的所有写操作都遵循同一条铁律——只动"我提议的、还没终结的、属于我的"那一行。命名空间键 + 状态守卫 + 租户守卫,三者缺一,就会出现"A 请求的成功被 B 请求标成失败"这种幽灵 bug。
9. 代表性工具:一句话职责 + 关键指针
前面讲的是"骨架"。真正干活的是 tools/ 目录里 20 多个工具。挑四把最能说明设计意图的:
9.1 internal_search —— 把 RAG 检索包成工具
职责: 让 LLM 自己决定何时、搜什么,而不是把文档预填进 prompt。
internal = True——所以它不进自动菜单,由检索层显式注入(internal_search.py:24)。- 两个动作:
search(检索)和list_files(浏览目录结构,仅当源有directory_structure时才暴露)——internal_search.py:128execute_action。 - 合成注入的关键坑: 它没有 DB 行。
add_internal_search_tool必须给它盖上哨兵 idINTERNAL_TOOL_ID = "internal"(internal_search.py:319),否则执行器的_get_or_load_tool会因"缺 id"把它丢弃(tool_missing_row_id)——internal_search.py:456。 - 检索本身走第 4 章的
build_dispatcher(按源路由),kill-switch 时回退 legacy classic 检索——internal_search.py:59。
9.2 mcp_tool —— 接远程 MCP 服务器
职责: 用 FastMCP 客户端连上一台 MCP 服务器,把它的工具动态变成本地动作。
- 动作元数据运行时才知道:
discover_tools调list_tools,get_actions_metadata把远端 schema 翻成本地格式——mcp_tool.py:328、mcp_tool.py:542。 - SSRF 防护:
server_url过validate_url,指向私网就抛错——mcp_tool.py:95_validate_server_url。STDIO transport 被明确禁用(mcp_tool.py:217)。 - OAuth 双模: 交互模式
DocsGPTOAuth走前端重定向 + Redis 传 code;查询执行时用NonInteractiveOAuth,401 直接快速失败而不是卡住流式响应等一个不会来的授权——mcp_tool.py:815。token 存在 Postgres 的connector_sessions(DBTokenStorage)。 - 客户端有 5 分钟进程内缓存(
_mcp_clients_cache,mcp_tool.py:147),401 时清缓存重连再试一次。
9.3 api_tool —— 任意 HTTP API 变工具
职责: 用户在配置里描述一个 HTTP 端点(URL / method / 参数),它就变成一个可调工具。
get_actions_metadata返回空[]——动作全由用户定义,不是代码写死的(api_tool.py:231)。执行器对api_tool特判:参数分 query_params / headers / body 三桶,结果不缓存。- 路径参数注入 + SSRF 防护: URL 里
{param}占位用quote转义后替换,其余进 query string;请求走pinned_request(把 DNS 钉死防 rebinding),不安全 URL 抛UnsafeUserUrlError——api_tool.py:130。 - body 序列化交给
RequestBodySerializer,支持按 OpenAPI encoding rules 处理多种 content-type——api_tool.py:107。
9.4 code_executor —— 沙箱执行
职责: 在一个绑定到会话的沙箱里跑 Python,代码写出的文件自动变成可下载 artifact。
- 只有一个动作
run_code;preview_decision返回(require_approval, False)——审批看部署级 config,从不 denylist 强制(code_executor.py:180)。 - 只回摘要,绝不回原始字节: stdout/stderr 只取尾部
_OUTPUT_TAIL_BYTES = 4000;产出文件转成 artifact 引用——code_executor.py:405_shape_payload。 - 会话 id 从 conversation/workflow_run 派生并按网关字符集
[A-Za-z0-9_-]清洗——code_executor.py:441。 - 超时不可调: 每次
run_code被SANDBOX_EXEC_TIMEOUT(默认 60s)硬顶;长任务要模型自己起后台进程 +persist=true保活会话再轮询——code_executor.py:405。 - 输入 artifact 走 parent-scoped 网关解析(短 ref
A1也只在本会话内解析),防止 ref 跨租户——code_executor.py:257_materialize_inputs。
10. 默认工具:不用配置就在的那些
default_tools.py 解决一个问题:有些工具(如 think、read_webpage)config-free、该默认开,但它们没有 user_tools 表里的真实行。方案是合成一份 user_tools-形状的伪行,盖上确定性的 uuid5 id。
两个由本章执行器直接消费的函数:
synthesized_default_tools(user_doc, headless=...)——default_tools.py:304:为无 agent 的聊天返回默认工具行。它在ToolExecutor._get_user_tools里被合并进用户工具集(tool_executor.py:346)。会扣掉用户手动关掉的、以及 headless 下排除的。is_headless_excluded_tool(name)——default_tools.py:327:判断一个工具名是否必须从 headless run 隐藏。目前名单是{"scheduler"}——因为让一个定时任务里的 LLM 去调schedule_task,会每次触发都链式新建计划,是成本陷阱。执行器的三条工具解析路径(_get_tools_by_ids/_get_tools_by_api_key/_get_user_tools)都调它做过滤。
一个 schema 陷阱(_FK_BOUND_TOOLS): notes、todo_list 的存储表把 tool_id 外键到 user_tools.id。合成工具的 id 没有真实行,写入会 FK 违约——所以 validate_default_chat_tools 在启动时硬拒把这类工具设为默认开(default_tools.py:201)。这解释了为什么它们出现在 USER_SCOPED 名单(§4.2)、却不能进默认工具。