第 3 章 给它一双手:工具与 Toolkit
本章代码
code/ch03/main.py· 读完约 15 分钟
3.0 上一章留下的问题
第 1 章那个 Agent 有一个致命缺陷:它只能凭记忆回答一切问题。
你可以试试问它:"现在几点?"
它会给你一个格式完全正确的时间,语气非常自信,而这个时间没有任何参考价值。原因在 1.1 节已经说过——它不知道今天星期几,也不知道此刻是几点。模型的知识停留在训练数据截止的那一天,之后就断了。
那它为什么还要回答?因为它被训练成"尽量给出有用的回答"。没有信息时,它会用最像答案的内容填补空白。这个行为有个不太好听的名字:幻觉。
这一章要解决的,就是让它在不知道的时候,有地方去查。
3.1 工具到底是什么
很多教程会把工具讲成"给模型加一个函数"。这个说法容易引起误解,让人以为模型内部会调用你的代码。
事实是:模型从头到尾没有执行过任何东西。
它能做的只有两件事:读文本、写文本。所谓"调用工具",实质是模型输出了一段特定格式的文本,然后由框架去执行,再把结果作为新文本喂回去。

拆开看,一次调用包含四次交接:
| 步骤 | 谁做的 | 发生了什么 |
|---|---|---|
| ① | Agent | 把"有哪些工具、每个工具要什么参数"随请求一起发给模型 |
| ② | 模型 | 返回一个 tool_call:工具名 + 参数 JSON,注意这不是自然语言 |
| ③ | Toolkit | 找到对应工具,校验参数,真正执行 |
| ④ | Agent | 把执行结果包装成 ToolResultBlock 写回上下文,再问一次模型 |
模型在第 ② 步输出的东西长这样:
{
"name": "Read",
"arguments": { "file_path": "a.txt" }
}它没有读到 a.txt,一个字都没有。它只是说了一句"请帮我读 a.txt"。真正打开文件的是第 ③ 步的 Read 工具。
理解这一点之后,很多事情就顺了:为什么工具描述写得不好会导致调用失败、为什么工具执行报错不会让程序崩溃(错误会作为结果回传给模型,让它自己想办法)、为什么权限拦截可以发生在第 ② 步和第 ③ 步之间。
3.2 模型怎么知道有哪些工具
靠一份结构化说明书,也就是上一节第 ① 步里那份 JSON Schema。
你不需要手写 JSON。AgentScope 提供了一个适配器 FunctionTool:给它一个普通的 Python 函数,它自动完成转换。
from agentscope.tool import FunctionTool, Toolkit
def count_text(text: str) -> str:
"""统计一段文本的字符数、中文字数与行数。
Args:
text: 需要统计的文本内容。
"""
...转换结果是这样的:

三样东西被完整地用上了:
| 你写的 | 变成了 | 模型怎么用 |
|---|---|---|
函数名 count_text | name | 调用时指名道姓 |
| docstring 第一行 | description | 判断什么时候该用这个工具 |
类型注解 text: str | parameters.type | 构造合法参数 |
Args: 下的说明 | 参数的 description | 理解每个参数该填什么 |
这张图解释了一个常见现象:同一个模型,面对两个功能相同的工具,一个调得好、一个却出问题。差别也许不在代码逻辑,docstring 写得不好也是重要的原因。
写工具描述的标准 不是"这个函数做了什么",而是"什么情况下应该用它"。 对比这两句:
计算两个数的和—— 模型得自己判断什么时候该算加法执行精确的算术运算。当需要计算、核对数字时必须使用本工具,不要心算—— 模型知道该在什么时候伸手 后者并不更长,但它给出了触发条件。
3.3 装配工具
有了函数,还要把它们登记到一个地方。这个登记处叫 Toolkit。
def build_toolkit() -> Toolkit:
return Toolkit(
tools=[
FunctionTool(get_current_time),
FunctionTool(count_text),
Read(), # 内置:读文件
Write(), # 内置:写文件
Bash(), # 内置:执行 shell 命令
],
)Toolkit 在整本书里扮演三个角色:
- 注册表:记住有哪些工具
- 出票员:把每个工具转成 JSON Schema,每次请求模型时附上
- 调度员:模型说要调
Read,它负责找到那个Read实例并执行
前两个角色你现在就能验证。Toolkit 有一个 get_tool_schemas() 方法(注意它是异步的),可以直接打印出"模型看到的世界":
async def print_tool_schemas(toolkit: Toolkit) -> None:
for schema in await toolkit.get_tool_schemas():
fn = schema.get("function", schema)
params = fn.get("parameters", {}).get("properties", {})
desc = (fn.get("description") or "").splitlines()[0]
print(f"· {fn.get('name')}")
print(f" 描述:{desc}")
print(f" 参数:{list(params.keys()) or '无'}")运行结果(真实输出):
==================================================================
模型能看到的工具清单
==================================================================
· get_current_time
描述:获取当前的日期和时间。
参数:无
· count_text
描述:统计一段文本的字符数、中文字数与行数。
参数:['text']
· Read
描述:Reads a file from the local filesystem. You can access any file directly by using this tool.
参数:['file_path', 'offset', 'limit', 'pages']
· Write
描述:Writes a file to the local filesystem.
参数:['file_path', 'content']
· Bash
描述:Executes a bash command and returns its output.
参数:['command', 'description', 'timeout']这就是模型在第 ① 步收到的全部信息。它没有看过你的代码,只看了这五行摘要。
一个立刻能做的观察 五个工具,模型的"选择空间"是 5。等你加到 30 个工具,它挑错的概率会明显上升。这就是第 6 章要讲工具组、第 8 章要讲上下文管理的原因——工具不是越多越好,而是越清晰越好。
3.4 内置工具
AgentScope 自带一批覆盖常见操作的实现,直接实例化就能用:
| 工具 | 作用 | 只读 |
|---|---|---|
Bash | 执行 shell 命令 | 否 |
PowerShell | Windows 上执行 PowerShell | 否 |
Read | 按行号读取文件,返回带行号的文本 | 是 |
Write | 创建或覆盖文件 | 否 |
Edit | 在文件中做精确字符串替换 | 否 |
Glob | 按文件名模式查找 | 是 |
Grep | 按内容搜索(基于 ripgrep) | 是 |
TaskCreate / TaskList / TaskUpdate | 维护任务清单 | 否 / 是 / 否 |
AskUser | 向用户提出选择题(需 2.0.9+) | 是 |
"能执行代码"就是
Bash看到这里可能会想知道:这 Agent 到底能不能跑代码?能——但它没有叫run_code的工具。python fizz.py、pytest、npm run build,走的全是第一行那个Bash。 反过来说也成立:第 1 章那个没有工具的 Agent 一个字都跑不了;把本章这五行工具里的Bash删掉,它立刻退化成只会读写文件的助手。执行代码不是一项独立能力,就是"能开一个 shell"的副产品。 甚至你可以将python脚本封装成工具,agent能直接调用你本地的python工具。事实上workbuddy内置了python工具包,workbuddy的agent可以直接调用python脚本,来实现更复杂的任务。
这张表里最重要的不是工具名,而是最后一列。它标注的是"这个工具会不会改变世界"——第 7 章的权限系统完全建立在这一列上:只读工具可以放行,会写东西的工具必须拦住。
Write 还有一条容易被忽略的规则:先读后写。如果目标文件已经存在而你从未读过它,Write 会直接失败。这个设计是为了避免模型在没有看到原文的情况下盲目覆盖。
你现在可能觉得这条规则碍事。等到第 5 章它替你保住一份重要配置时,你会改变看法。
3.5 完整代码
"""第 3 章:给它一双手。
比上一章多了什么:
1. Toolkit —— 工具注册表
2. 两个自定义工具(用 FunctionTool 把普通函数变成工具)
3. 三个内置工具(Read / Write / Bash)
"""
import asyncio
import os
import sys
from datetime import datetime
from dotenv import load_dotenv
from agentscope.agent import Agent
from agentscope.credential import OpenAICredential
from agentscope.message import UserMsg
from agentscope.model import OpenAIChatModel
from agentscope.permission import PermissionMode
from agentscope.tool import Bash, FunctionTool, Read, Toolkit, Write
load_dotenv(override=True)
API_KEY = os.getenv("OPENAI_API_KEY", "")
BASE_URL = os.getenv("OPENAI_BASE_URL", "")
MODEL_NAME = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
def build_model() -> OpenAIChatModel:
return OpenAIChatModel(
credential=OpenAICredential(api_key=API_KEY, base_url=BASE_URL or None),
model=MODEL_NAME,
)
def get_current_time() -> str:
"""获取当前的日期和时间。
当用户询问"现在几点""今天几号""星期几"这类问题时使用本工具。
你无法感知真实时间,遇到时间问题必须调用本工具,不要凭记忆回答。
"""
now = datetime.now()
week = "一二三四五六日"[now.weekday()]
return now.strftime("%Y-%m-%d %H:%M:%S") + f",星期{week}"
def count_text(text: str) -> str:
"""统计一段文本的字符数、中文字数与行数。
Args:
text: 需要统计的文本内容。
"""
if not text:
return "文本为空。"
chinese = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
lines = text.count("\n") + 1
return f"总字符数 {len(text)},其中中文 {chinese} 个字,共 {lines} 行。"
def build_toolkit() -> Toolkit:
"""把工具装配成一个 Toolkit。"""
return Toolkit(
tools=[
FunctionTool(get_current_time),
FunctionTool(count_text),
Read(),
Write(),
Bash(),
],
)
async def print_tool_schemas(toolkit: Toolkit) -> None:
"""打印工具清单——这就是模型在每次推理前看到的东西。"""
print("=" * 66)
print("模型能看到的工具清单")
print("=" * 66)
for schema in await toolkit.get_tool_schemas():
fn = schema.get("function", schema)
params = fn.get("parameters", {}).get("properties", {})
desc = (fn.get("description") or "").splitlines()[0]
print(f"· {fn.get('name')}")
print(f" 描述:{desc}")
print(f" 参数:{list(params.keys()) or '无'}")
print()
async def main() -> None:
toolkit = build_toolkit()
await print_tool_schemas(toolkit)
agent = Agent(
name="Buddy",
system_prompt=(
"你叫 Buddy,是一个住在命令行里的工作助手。"
"需要用工具获取信息时必须调用工具,不要凭记忆猜测。短答即可。"
),
model=build_model(),
toolkit=toolkit,
)
# 这一章的脚本没有确认界面。Bash 这类工具默认会"停下来等人点头",
# 在这个没有界面的场景里就会卡住,后面再发消息还会报错。
# 先整体放行,把注意力放在"工具能不能用起来";第 7 章专门讲怎么管住它。
agent.state.permission_context.mode = PermissionMode.BYPASS
# 让 shell 里的 python 一定是当前这个解释器。原因见 3.5.1。
os.environ["PATH"] = os.path.dirname(sys.executable) + os.pathsep + os.environ.get("PATH", "")
questions = [
"现在几点?今天星期几?",
"帮我数一下这句话有多少字:今天天气不错,适合写代码。",
# 第三问故意让它"写 + 跑":一次触发 Write、Bash 两个工具。
"把 1 到 20 的 FizzBuzz 写成 fizz.py,"
"然后用 python fizz.py 跑一遍,把标准输出原样贴给我。",
]
for question in questions:
print(f">>> 你:{question}")
reply = await agent.reply(UserMsg(name="user", content=question))
print(f"<<< Buddy:{reply.get_text_content()}")
print()
if __name__ == "__main__":
asyncio.run(main())需要注意 system_prompt 里新增的那句话:
需要用工具获取信息时必须调用工具,不要凭记忆猜测。
这句话不是装饰。模型有很强烈的"直接回答"倾向,尤其面对简单问题时。明确要求它在信息不足时调用工具,能显著降低幻觉率。这是提示词工程里最实用的技巧之一:给模型一条撤退路线,它才不会硬编。
为什么这三问写死在代码里。 第 1 章给了你一个能连着提问的循环,这一章反而退回固定脚本——因为本章要看的是"模型在第几次调用时才想起用工具""一次 Write+Bash 会产出几条事件",这些必须每次跑都一样才看得清。想自己追问,把上面的
questions换成第 1 章那个input()循环即可,工具一样能用。
3.5.1 加一个"动手"的问题
前两问都只是"查一下",答完就结束。第三问才是分水岭:它要造出一个东西,再证明这个东西真能跑。
真实输出(模型措辞每次不同,结构一致):
>>> 你:把 1 到 20 的 FizzBuzz 写成 fizz.py,然后用 python fizz.py 跑一遍,把标准输出原样贴给我。
<<< Buddy:`fizz.py` 已创建在 `code/ch03/`,运行 `python fizz.py` 的标准输出:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz这一问里,循环转了两圈:第一圈调 Write 落盘,第二圈调 Bash 执行。那 20 行输出不是模型"写"出来的——它只知道"我请框架跑了一条命令",输出是第 ④ 步回传进上下文的。它和你几乎同时看到这些数字。
顺带回答一个被反复问到的问题:"能执行代码"不是一项独立能力。 这里没有 run_code 工具,python fizz.py 走的就是 3.4 表里第一行那个 Bash。pytest、npm run build、git commit 全是同一条路。把 Bash 从 Toolkit 里删掉,本章的 Agent 立刻退回"只会读写文件"。
为什么不把解释器的绝对路径写进命令
听起来更保险,Windows 上实测三个写法倒两个(同为 Git Bash 环境,各机器可能略有差异):
| 命令 | 结果 |
|---|---|
"E:/anaconda/python.exe" fizz.py | '"E:/anaconda/python.exe"' 不是内部或外部命令 —— 引号让它变成一个整体的命令名 |
E:\anaconda\python.exe fizz.py | 系统找不到指定的路径 —— 反斜杠在 shell 里是转义符 |
python fizz.py | ✅ |
所以代码里多出那行 PATH:把当前解释器所在目录顶到最前面,命令里只写 python,让 shell 自己去查。跨平台、无引号、无转义。
这类坑很枯燥,但它比任何概念都更容易劝退人。零基础读者卡住的地方,很少是"ReAct 是什么",多半是"我照着敲了,为什么跑不起来"。
写错了会怎样
Bash 的 stderr 不会被框架吞掉,它作为工具结果原样进上下文。模型下一轮看见 SyntaxError: invalid syntax,自己改代码再跑——没人教它这么做,这是"看到结果再决定下一步"的必然产物。第 4 章会把这一轮彻底拆开。
再跑一次本章脚本,还会撞上另一条规则:fizz.py 已经存在,Write 要求先读。实测里模型的回复是:
fizz.py已写入并运行(注意:该文件原本内容是print(1),已被覆盖)。
它能说出旧内容,说明它读过。这就是 3.4 那句"先读后写"在你眼前生效的样子。
3.6 三个常见错误
错误 1:函数没有类型注解
def count_text(text): # 少了 : str
...生成的 schema 里 parameters 会是空的。模型不知道要传什么,于是可能不传参数,或者传一个猜的参数。症状是工具被调用了,但每次都报"缺少必要参数"。
错误 2:docstring 只写了函数名
def count_text(text: str) -> str:
"""统计文本。""""统计文本"这四个字,模型无法判断该在什么时候用:统计字数?统计词频?统计行数?结果就是它该用的时候不用,不该用的时候乱用。
写 docstring 的时候,把自己当成在跟一个刚入职的同事交代工作:什么情况用、输入是什么、输出是什么。
错误 3:工具返回了非字符串
def get_stats(text: str) -> dict: # 返回 dict
return {"chars": len(text)}工具的执行结果最终要作为文本进入模型的上下文。返回 dict 在简单场景下可能被自动序列化,但复杂对象、None、异常对象都可能出问题。统一返回字符串,需要结构化数据时自己 json.dumps(..., ensure_ascii=False)。
3.7 小结
- 工具的实质是模型输出一段结构化申请,框架代它执行
FunctionTool把函数名、docstring、类型注解转成模型能读的 JSON Schema- docstring 要写"什么情况下用它",而不是"它做了什么"
Toolkit是注册表、出票员、调度员的合体- 内置工具里,只读与否这一列比工具名更重要
- 执行代码没有专用工具:
Bash/PowerShell就是。写代码 → 跑起来 → 看报错,是检验工具链最短的一条路
下一章要解决的问题:第 3.1 节那张图里,第 ⑤ 步写着"再来一轮"。这一轮到底转了几次、什么时候停、停不下来怎么办——这就是 ReAct 循环,也是整套框架真正的心脏。
