跳到主要内容

工具系统:代码块即工具调用

30 秒导读: 大多数 agent 框架把"用工具"这件事交给模型厂商的 function-calling API。gptme 反其道而行:让模型在正文里写一个代码块(比如 ```shell),gptme 自己把这段文本解析出来、认出它对应哪个工具、再执行。这一章讲清楚这套"文本即调用"的机制——它的数据模型(ToolSpec)、解析器(ToolUse)、和执行管线。

本章属于 gptme 系列。想先看它整体怎么转,读 index;工具执行结果如何回灌进主循环,见 01-agent-loop;某个具体工具(shell/python/编辑/浏览)的领域逻辑,见 03-builtin-tools;执行前的确认与护栏怎么挂上去,见 05-hooks-extensibility


1. 这是什么(零基础也能懂)

一句话定义: gptme 的"工具调用"= 模型在回复里写一段带语言标签的代码块,gptme 把它当成一次工具调用来解析和执行,而不是依赖模型 API 的结构化 function-call 通道。

它解决什么问题。 假设你想让 AI 在终端里帮你干活——跑个命令、改个文件、打开个网页。AI 本身只会"输出文本",它没有手。要让它真的动手,得有人:

  1. 定义"有哪些手"(有哪些工具、每个工具怎么用);
  2. 从 AI 的文本回复里认出"它现在想用哪只手、参数是什么";
  3. 真的去执行,再把结果塞回给 AI。

第 2、3 步就是本章的主角。

两条路线的分岔。 业界主流是让模型厂商替你做第 2 步——OpenAI/Anthropic 的 API 有专门的 tool_calls 字段,模型吐出结构化 JSON,你照着调函数。gptme 支持这条路(它叫 tool 格式),但默认走另一条:让模型把调用写在正文的 Markdown 代码块里,gptme 自己解析。

为什么要自己解析? 因为这样工具调用就不依赖特定模型的 API 能力——任何能输出文本的模型(哪怕是本地小模型、哪怕没有 function-calling)都能用工具;而且调用过程对人完全可读,就是一段普通的代码块。

用起来什么样。 模型想执行一条 shell 命令,它的回复里会直接出现这样一段:

我来看看当前目录有什么文件:

```shell
ls -la
```

gptme 扫描这段回复,发现 ```shell 这个语言标签对应内置的 shell 工具,于是把 ls -la 抽出来执行,把输出作为一条 system 消息接到对话后面。模型下一轮就能看到结果。

一句话直觉。 把它想成:语言标签(langtag)就是函数名,代码块正文就是参数```shell 约等于 shell("ls -la"),只不过写成了人也能读的代码块。


2. 顶层全景(它大概怎么转)

三个核心文件

部件干什么在哪
ToolSpec工具的数据模型:名字、怎么执行、认哪些代码块、可用性、参数…gptme/tools/base.py:305
ToolUse一次具体调用:从模型文本里解析出来,并负责执行它gptme/tools/base.py:661
Codeblock底层的代码块解析器:从 Markdown 里抠出 ``` 围栏gptme/codeblock.py:11
注册与筛选发现所有工具、按 allowlist 筛、把某条消息里的调用全执行掉gptme/tools/__init__.py

一次工具调用的生命线

从模型输出到执行完毕,数据这样流动(从上到下):

模型输出一段文本(含 ```shell ... ``` 或 <tool-use> 或 @tool(id):{...})


ToolUse.iter_from_content(content) 按当前 tool_format 选解析器
│ ├─ markdown → _iter_from_markdown → Codeblock.iter_from_markdown
│ ├─ xml → _iter_from_xml
│ └─ tool → toolcall_re 扫 @name(id):{json}

得到若干 ToolUse(tool 名, args, content, kwargs...) 按出现位置排序


execute_msg 只留下 is_runnable 的,逐个执行


ToolUse.execute: pre 钩子 → 计时 → tool.execute(...) → post 钩子 → 遥测


yield Message("system", 结果) 回灌给主循环

主线一句话: 模型的一段回复 → iter_from_content 解析出零或多个 ToolUseexecute_msg 逐个 execute → 每个产出若干 system 消息接回对话。这条线的两端(解析、执行)就是下面 §3、§4 的内容。


3. 核心原理

3.1 三种 tool_format:同一个调用,三种写法

它要解决的小问题。 不同模型"擅长"的输出方式不同:有的跟着 Markdown 代码块很稳,有的对 XML 标签更听话,有的干脆有原生 function-calling。gptme 用一个全局变量 tool_format 在三者间切换,而工具本身的定义完全不变

三种格式对照:

格式模型该怎么写谁来解析取舍
markdown(默认)带 langtag 的代码块 ```shell_iter_from_markdown最通用、人最易读;靠围栏规则,偶有歧义
xml<tool-use><shell>…</shell></tool-use>_iter_from_xml标签边界清晰、能容 </>;更啰嗦
tool@shell(id):{"command":"ls"} + 原生 tool_callstoolcall_re 扫描贴合厂商 API、有 call_id;依赖模型支持

全局开关就是模块级变量 tool_format,默认 "markdown",由 set_tool_format() 改写(gptme/tools/base.py:45-48141-147);启动时在 gptme/init.py:62 按配置设定。

markdown 与 xml 的输出侧。 同一个 ToolUse 能反向渲染成任一格式,见 to_output 分派到 _to_markdown / _to_xml / _to_toolcall(gptme/tools/base.py:1019-1088)。注意 _to_markdown 永远用三个反引号(base.py:1030),这个约定后面解析器要用到(见 §3.4 的容错)。

3.2 ToolSpec:一个工具是什么

思路。 与其让每个工具去实现一堆接口,gptme 用一个 frozen dataclass ToolSpec 把"一个工具"需要的所有信息声明式地装在一起。定义一个工具 = 造一个 ToolSpec 实例。

最关键的几个字段:

字段含义为什么重要
name工具名(如 "shell")查找、allowlist 匹配都靠它
execute执行函数 `(code, args, kwargs) -> MessageGenerator`
block_types认哪些 langtag(如 ["shell"])解析时靠它把代码块派给对应工具
availablebool() -> bool依赖缺失/服务没起时自动隐藏
instructions / examples进 system prompt 的用法说明教模型怎么用这只手
parametersParameter 列表tool 格式生成 JSON schema、给位置参数命名
load_priority加载排序权重,越大越晚例:ipython 设 10 让它靠后
disabled_by_default默认不启用,需显式点名危险/特殊工具(computer、complete)默认关
hooks / commands随工具一起注册的钩子与斜杠命令工具能自带护栏和 /命令

is_runnable 就是"有没有 execute":

# gptme/tools/base.py:487-490 —— 真实源码
@property
def is_runnable(self) -> bool:
"""Check if the tool can be executed."""
return bool(self.execute)

有些 ToolSpec 没有 execute(纯说明性/信息性工具),它们能进 prompt 但不会被 execute_msg 挑去执行。

一个真实定义长这样(shell 工具,gptme/tools/shell.py:1962-1983):它给了 name="shell"execute=execute_shellblock_types=["shell"],还挂了两个 hook(allowlist 自动确认、会话结束清理)。ipython 工具则把 block_types 设成 ["ipython", "py"](gptme/tools/python.py:456-461)——一个工具可以认多个 langtag

init 与延迟构造。 有些工具在被加载时才知道自己长什么样(比如 ipython 要枚举注册进 REPL 的函数)。ToolSpec.init 是个可选的 () -> ToolSpec,加载时调用它拿到"最终版"的 spec(见 §4.3 的 _init_single_tool)。

instructions_format:给某个格式单独写说明。 get_instructions 里,如果某工具为当前 tool_format 提供了专门文案,就用它替换自动生成的函数清单;否则回退到通用 instructions + 函数描述(gptme/tools/base.py:492-508)。这是为了在 tool 格式下把说明压进 OpenAI 的 1024 字符上限(代码注释点名 issue #1697)。

3.3 langtag 派发:代码块怎么找到它的工具

它要解决的小问题。 解析出一个代码块后,shellipythonpatch…到底该交给谁?靠 langtag 的第一个词匹配 block_types

# gptme/tools/__init__.py:347-357 —— 真实源码
def get_tool_for_langtag(lang: str) -> ToolSpec | None:
block_type = lang.split(" ")[0] # "save foo.py" → "save"
for tool in _get_loaded_tools():
if block_type in tool.block_types:
return tool
return None

注意 lang.split(" ")[0]:langtag 可以带参数。```save path/to/file.py 的 block_type 是 save,剩下的 path/to/file.py 是参数。

参数从哪来。 ToolUse._from_codeblock 把一个 Codeblock 转成 ToolUse 时,对大多数工具,args = langtag 去掉第一个词后剩下的部分;但对 save/append/patch特例——它们要的是整条 langtag(因为路径就在里面):

# gptme/tools/base.py:816-828 —— 真实源码(节选)
args = (
codeblock.lang.split(" ")[1:]
if tool.name not in ["save", "append", "patch"]
else [codeblock.lang]
)
return ToolUse(tool.name, args, codeblock.content, start=codeblock.start, _format="markdown")

派发不上任何工具的代码块(普通的 ```json```python)返回 None,被当成普通文本略过。

3.4 从文本解析出调用:iter_from_content

思路。 这是解析的总入口。它按 active_format 选解析器,把结果按在原文里出现的位置排序(因为一条消息里可能有多个调用,执行顺序要和书写顺序一致)。

# gptme/tools/base.py:836-865 —— 真实源码(节选)
active_format = tool_format_override or tool_format
tool_uses: list[ToolUse] = []
if active_format == "xml":
tool_uses = list(cls._iter_from_xml(content))
if active_format in ("markdown", "tool"):
# "tool" 格式也要解析 markdown 块(供 /impersonate 和人工输入的调用)
tool_uses = list(cls._iter_from_markdown(content, streaming=streaming))
# 按位置排序;位置未知(None)的排最后
tool_uses.sort(key=lambda x: x.start if x.start is not None else len(content))
yield from tool_uses

几个值得注意的设计:

  • tool 格式也跑 markdown 解析——因为即便走原生 function-call,人工/impersonate 注入的代码块也得能被认出(base.py:857-860 的注释)。
  • 排完 markdown 的部分后,tool 格式继续toolcall_re@name(id):{json}(base.py:868-914)。这里用了两个巧思:
    • 不用 finditer:因为 re.DOTALL 的贪婪匹配会从第一个 { 一路吞到结尾,所以改成"从上一个 JSON 结束位置继续搜"的循环。
    • find_json_end 数花括号(处理字符串内的 {、转义)来精确定位 JSON 结束(base.py:56-80);JSON 不完整(流式途中)就 break
    • _codeblock_char_ranges 求出所有代码块的字符区间,跳过落在代码块里的 @tool(...),防止文档/示例里的调用语法被误当成真调用(base.py:83-115878-889)。

markdown 解析最终落到 Codeblock.iter_from_markdown,再对每个块试 _from_codeblock(base.py:916-933)。

xml 解析用 lxml 的 HTMLParser(比严格 XML 宽容),同时支持两种标签:gptme 自家的 <tool-use><工具名>…</工具名></tool-use>,和 Haiku 风格的 <function_calls><invoke name="…">…</invoke></function_calls>(base.py:935-1017)。它用 itertext() 拼接子元素文本,这样代码里的 <><filename> 这类尖括号 token 不会被吞掉。

3.5 Codeblock 解析器:最难的那块围栏活

它要解决的小问题。 "从 Markdown 里抠出代码块"听起来简单,实则是本项目最刁钻的解析——因为代码块里可以再套代码块(模型演示怎么写工具时,会在 ``` 里写 ```),而模型还经常把围栏写坏。

它的核心手法(gptme/codeblock.py:96 _extract_codeblocks):

  • 变长围栏 + 嵌套深度。 开围栏可以是 3 个或更多反引号;用 nesting_depth 跟踪嵌套,只有**长度精确匹配开围栏、且整行只有反引号(bare fence)**的行才算关掉最外层(codeblock.py:253-261)。
  • 前后文判断"这是开还是关"。 一个裸围栏到底在开新块还是关旧块,靠看下一行有没有内容、是不是也是围栏来决定(codeblock.py:264-277)。
  • 流式模式的保守策略。 streaming=True 时要求围栏后有空行才确认闭合,避免把还没写完的块提前抽出来(codeblock.py:273351-358364-384)。
  • 容错坏围栏。 模型有时把上一块的收尾和下一块的开头黏在一行,写成 ``````shell 而不是 ``` 换行再 ```shell。因为 _to_markdown 总用三反引号,所以"六个反引号紧跟非空白"是个可靠信号——把前三个当作上一块的收尾拆开(codeblock.py:191-193216-236)。
  • 不从思考块里抠代码。 Claude 有时在 <thinking> 里忘了闭合围栏、Gemini 用 </thinking>,所以解析前先把成对/畸形的思考标签处理掉(codeblock.py:117-172)。

这些细节读者不必全记,记住一句:它把"代码块即工具调用"这个大胆设计所必须承担的解析复杂度都吃在这里了——正是因为选择了让模型写代码块,才需要一个这么硬核的围栏解析器。


4. 执行管线与工具注册(深入实现)

4.1 ToolUse.execute:一次执行都发生了什么

思路。 解析出的 ToolUse 自己负责执行。这段是整套系统的"心脏",按顺序做了这些事(gptme/tools/base.py:671-793):

  1. 查工具:get_tool(self.tool);没有或没有 execute 就跳过(base.py:685-686790-791)。
  2. pre 钩子:触发 HookType.TOOL_EXECUTE_PRE,把 ToolExecutePreData(含 log、workspace、tool_use)传进去;钩子可以产出消息(比如确认拦截)(base.py:688-700)。
  3. 设 contextvar:token = _current_tool_use.set(self)。这样工具内部调 get_current_tool_use()get_confirmation() 时,不必显式传 tool_use 就能拿到当前调用(base.py:713-715136-138)。finallyreset(token) 复位。
  4. 计时 + 执行:记 start_time,调 tool.execute(self.content, self.args, self.kwargs)
  5. Generator vs 单 Message 的兼容:execute 可以返回一个生成器(逐条 yield 结果,支持流式/多条输出),也可以返回单个 Message。代码判断 isinstance(ex, Generator) 分别处理,并对每条结果调 on_result_message 回调再 yield(base.py:718-739)。
  6. 遥测:record_tool_call(self.tool, duration=…, success=True, tool_format=self._format)(base.py:747-752)。
  7. post 钩子:触发 TOOL_EXECUTE_POST,把结果消息作为 result_msgs 传给钩子(base.py:754-769)。
  8. 异常兜底:任何异常都记一次 success=False 的遥测(带 error_type/message);测试环境("pytest" in globals())直接 raise,否则把异常转成一条 system 错误消息 yield 回去——让模型看到报错、自己重试,而不是让整个循环崩掉(base.py:771-789)。

整个函数还包在 @trace_function(name=f"tool.{self.tool}") 里做 tracing(base.py:683)。

一句话记住: execute = 钩子夹计时夹执行,结果和异常统一变成 system 消息回灌;contextvar 让确认逻辑能隐式拿到当前调用。

4.2 execute_msg:把一条消息里的调用全跑掉

主循环拿到模型消息后,调 execute_msg 汇总执行(gptme/tools/__init__.py:315-344):

# gptme/tools/__init__.py:324-336 —— 真实源码(节选)
runnable_tools = [
tu for tu in ToolUse.iter_from_content(msg.content) if tu.is_runnable
]
if not runnable_tools:
return
for tooluse in runnable_tools:
with terminal_state_title(f"🛠️ running {tooluse.tool}"):
...
for tool_response in tooluse.execute(log=log, workspace=workspace):
yield tool_response.replace(call_id=tooluse.call_id)

要点:先 iter_from_content 解析、只留 is_runnable 的;逐个执行并把每条结果打上 call_id;KeyboardInterrupt 会产出一条中断消息并 break(__init__.py:337-344)。它怎么被主循环调用,见 01-agent-loop

4.3 工具注册与筛选:init_tools / get_toolchain

发现所有工具。 get_available_toolsTOOL_MODULES(默认 gptme.tools)递归导入所有子模块,用 inspect.getmembers 找出每个模块里的 ToolSpec 实例(__init__.py:364-415112-135),再加上插件工具和 MCP 工具,最后 sort()(按 load_priority,同优先级按名字,见 ToolSpec.__lt__ base.py:475-478)。结果按上下文缓存。

初始化被选中的工具。 init_tools(allowlist) 是加载入口(__init__.py:156-232):

  1. 把 allowlist 拆成文件路径工具名两拨——带 .py///\ 的当文件,其余当名字(__init__.py:184-190)。
  2. 文件路径先走 load_from_file 加载(见 §4.4)。
  3. 工具名交给 get_toolchain(name_allowlist) 筛出该启用的内置工具。
  4. 对每个选中的工具跑 _init_single_tool:调它的 init()(若有)、register_hooks()register_commands()(__init__.py:144-153)。
  5. 校验:allowlist 里点名了却没匹配到的,分"不可用(警告跳过)"和"根本不存在(报错)"两种情形(__init__.py:213-230)。

全程用 threading.Lock()(_tools_init_lock)保证线程安全;工具状态存在 ContextVar 里,每个线程/异步任务有独立副本——这对服务器场景很关键(__init__.py:55-74)。

allowlist 的三种匹配。 get_toolchain 决定哪些工具进最终列表(__init__.py:251-312),匹配逻辑在 _allowlist.py:

写法例子含义
精确名/globshellbrowser.*fnmatchcase(tool.name, pattern) 通配匹配
hint 前缀hint:read-only匹配所有带该 hint 标签的工具,不看名字
无 allowlistNone全部可用工具(除 disabled_by_default)

tool_matches_allowlist / matching_allowlist_tools(gptme/tools/_allowlist.py:24-45)。disabled_by_default=True 的工具只有被显式点名才进链(__init__.py:296-298);MCP 工具若被 allowlist 漏掉会给一次性警告,提示用 <server>.* glob 收编(__init__.py:300-311)。

4.4 load_from_file:从 .py 文件加载第三方工具

思路。 用户可以 --tools path/to/mytool.py/tools load … 挂自己的工具。load_from_file 负责安全地把一个 Python 文件导进来,挑出其中的 ToolSpec(gptme/tools/base.py:1107-1147):

  • 校验:路径必须存在、是常规文件、后缀是 .py;用 resolve() 防符号链接攻击(base.py:1117-1124)。
  • 避免模块名冲突:用 spec_from_file_location 加一个含 hash(path) 的唯一模块名,防止两个都叫 tool.py 的文件在 sys.modules 里互撞(base.py:1126-1134)。
  • 发现:inspect.getmembers 找出模块里所有 ToolSpec 实例返回(base.py:1136-1147)。

这就是 gptme "工具即代码块"之外的另一半可扩展性:工具本身也只是一段普通 Python,声明一个 ToolSpec 就行,不需要注册中心。


5. 巧妙之处(可借鉴的技术)

  • 调用即代码块,解绑模型 API。 把"工具调用"降维成"模型正文里的一段带 langtag 的文本",让工具能力不依赖厂商 function-calling——任何文本模型都能用,且对人完全可读。代价是自己扛下围栏解析的全部复杂度(codeblock.py)。
  • 一份 ToolSpec,三种格式。 工具定义与 tool_format 正交:同一个 ToolSpec 在 markdown/xml/tool 三种世界里都能用,格式切换只在解析/渲染层发生(base.py:836-8651019-1088)。
  • contextvar 传当前调用。_current_tool_use 这个 ContextVar,让 get_confirmation() 之类的函数隐式拿到"我正在执行哪个工具",省掉一路透传参数(base.py:131-138713-715)。
  • 执行永不炸循环。 工具异常统一转成 system 错误消息回灌,模型看到报错自己纠正——把"错误处理"变成"对话的一部分"(base.py:771-789)。
  • 数花括号找 JSON 边界。 find_json_end 手写括号栈(处理字符串与转义),配合"从上次结束位置继续搜"绕开 DOTALL 贪婪问题,能在一段文本里稳稳切出多个 @tool(...) 调用(base.py:56-80868-914)。
  • 黏连围栏的定向修复。 因为输出侧 _to_markdown 恒用三反引号,解析侧就能用"六反引号紧跟语言标签"这一特征精准拆开被模型黏在一起的相邻围栏(codeblock.py:191-193216-236)。

6. 边界与局限

  • 围栏解析有本质歧义。 代码块套代码块、模型写坏围栏,靠一堆启发式规则和特例(见 codeblock.py 里大量 recovery 分支与注释)兜底;设计上承认"没有完美解",只能尽量鲁棒。
  • kwargs 都是字符串。kwargs/args 通道传进工具的值都是 str,需要 int/bool 的工具得自己在函数体里转换(base.py:614-618 from_function 的说明)。
  • tool 格式受厂商约束。 原生 function-call 走 OpenAI 通道时,工具说明要压进 1024 字符上限,靠 instructions_format 单独给精简版(base.py:498-504,issue #1697)。
  • load_from_file 会执行任意代码。 加载 .py 工具本质是 exec_module,只做了路径校验、没有沙箱——挂第三方工具等于信任其代码。

本章只讲"工具系统这套机制"。具体某个工具的领域逻辑(shell 怎么跑、patch 怎么改文件、browser 怎么驱动网页)不在这里,见 03-builtin-tools


7. 横向对比

同货架的编码 agent 里,"工具怎么被调用"有两种主流取舍:

  • 结构化 function-call 优先(多数基于 OpenAI/Anthropic API 的 agent):把解析交给模型厂商,拿到干净 JSON,代价是绑定支持该能力的模型。
  • 文本代码块优先(gptme):自己解析模型正文,换来模型无关 + 人类可读,代价是要维护 codeblock.py 这样的解析器。gptme 通过同时支持 tool 格式,保留了走第一条路的能力。

更细的跨库对比见总库对应原理页。


8. 代码地图(导航索引)

主题文件关键符号
全局格式开关gptme/tools/base.pytool_formatset_tool_formatget_tool_formatToolFormat
工具数据模型gptme/tools/base.pyToolSpecis_runnableis_availableget_instructionsinit
函数型工具gptme/tools/base.pyToolFunctionParameterfrom_functionas_function_subtoolspecs
一次调用gptme/tools/base.pyToolUse_from_codeblockto_output_to_markdown/_to_xml/_to_toolcall
解析入口gptme/tools/base.pyiter_from_content_iter_from_markdown_iter_from_xml
tool 格式扫描gptme/tools/base.pytoolcall_refind_json_endextract_json_codeblock_char_ranges
执行管线gptme/tools/base.pyToolUse.execute_current_tool_useget_current_tool_userecord_tool_call
加载 .py 工具gptme/tools/base.pyload_from_file
围栏解析gptme/codeblock.pyCodeblockiter_from_markdown_extract_codeblocksfrom_markdown
langtag 派发gptme/tools/__init__.pyget_tool_for_langtagis_supported_langtag
汇总执行gptme/tools/__init__.pyexecute_msg
注册与筛选gptme/tools/__init__.pyinit_toolsget_toolchainget_available_tools_init_single_toolload_tool
allowlist 匹配gptme/tools/_allowlist.pytool_matches_allowlistmatching_allowlist_toolsis_hint_patternallowlist_contains_glob