第 22 章:造一个新 Tool——数据库查询工具

难度:入门
你需要给 Agent 加一个数据库查询工具,让它能回答”我们有多少活跃用户?”这类问题。本章从零构建一个完整的 Tool,包括同步和流式两个版本。
上一章:第 21 章 扩展准备
任务目标
构建一个 SQLite 数据库查询工具,让 ReActAgent 能查询数据库并回答用户问题。
分三步走:
- 同步版本:最基础的查询工具,返回完整结果
- 流式版本:逐行返回查询结果,适合大数据集
- 集成验证:注册到 Toolkit,模拟调用链
1 | flowchart TD |
回顾:工具注册的流程
在第 10 章我们追踪了 Toolkit.call_tool_function() 的执行路径。现在我们从开发者视角看注册流程。
register_tool_function 的签名(_toolkit.py:274):1
2
3
4
5
6
7
8
9
10def register_tool_function(
self,
tool_func: ToolFunction,
group_name: str | Literal["basic"] = "basic",
preset_kwargs: dict | None = None,
func_name: str | None = None,
func_description: str | None = None,
json_schema: dict | None = None,
...
) -> None:
核心流程:
- 如果没传
func_name,用函数的__name__ - 如果没传
json_schema,从函数签名和 docstring 自动生成 - 检查重名,按
namesake_strategy处理 - 构造
RegisteredToolFunction存入self.tools
ToolResponse 的结构
ToolResponse(_response.py:12)是工具函数的返回类型:1
2
3
4
5
6
7
class ToolResponse:
content: List[TextBlock | ImageBlock | AudioBlock | VideoBlock]
metadata: Optional[dict] = None
stream: bool = False
is_last: bool = True
is_interrupted: bool = False
关键字段:
content:内容块列表,至少包含一个TextBlockstream:是否为流式响应is_last:流式时标记是否为最后一个 chunk
Step 1:同步版本
1.1 创建示例数据库
先用 SQLite 创建一个测试数据库:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27import sqlite3
def setup_demo_db(db_path: str = "demo.db") -> None:
"""创建演示用的用户数据库。"""
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 插入示例数据
conn.executemany(
"INSERT OR IGNORE INTO users (name, email, status) VALUES (?, ?, ?)",
[
("Alice", "[email protected]", "active"),
("Bob", "[email protected]", "active"),
("Charlie", "[email protected]", "inactive"),
("Diana", "[email protected]", "active"),
("Eve", "[email protected]", "active"),
],
)
conn.commit()
conn.close()
1.2 定义工具函数
1 | import sqlite3 |
1.3 注册并测试
1 | from agentscope.tool import Toolkit |
自动生成的 Schema 会包含:
sql参数(string 类型,required)db_path参数(string 类型,optional,默认 “demo.db”)- 从 docstring 提取的参数描述
1.4 模拟工具调用
不需要 API key,直接用 call_tool_function 测试:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import asyncio
from agentscope.message import ToolUseBlock
async def test_sync():
tool_call = ToolUseBlock(
type="tool_use",
id="call_test",
name="query_database",
input={"sql": "SELECT COUNT(*) as cnt FROM users WHERE status='active'"},
)
async for response in toolkit.call_tool_function(tool_call):
for block in response.content:
print(block["text"])
asyncio.run(test_sync())
Step 2:流式版本
当查询返回大量数据时,逐行返回结果比等全部查完再返回更好。这需要用 AsyncGenerator 实现流式。
2.1 流式工具函数
1 | import sqlite3 |
2.2 注册流式工具
1 | toolkit2 = Toolkit() |
注意:从 Toolkit 的角度看,同步和流式的注册方式完全一样。区别在于 call_tool_function(_toolkit.py:853)会检测返回类型——如果函数返回 AsyncGenerator,它会自动处理流式迭代。
call_tool_function 内部的处理逻辑(简化):1
2
3
4
5
6
7
8# _toolkit.py:870-1033(简化)
result = tool_func.original_func(**kwargs)
if isinstance(result, AsyncGenerator):
async for chunk in result:
yield chunk
elif isinstance(result, ToolResponse):
yield result
2.3 测试流式工具
1 | async def test_stream(): |
Step 3:集成到 Agent
3.1 使用 preset_kwargs 注入数据库路径
在生产环境中,你不希望模型知道数据库路径。用 preset_kwargs 把路径”预设”进去:1
2
3
4
5toolkit3 = Toolkit()
toolkit3.register_tool_function(
query_database,
preset_kwargs={"db_path": "/data/production.db"},
)
这样 JSON Schema 中不会出现 db_path 参数,但调用时 preset_kwargs 会和 tool_call["input"] 合并:1
2# _toolkit.py:937(简化)
kwargs = {**tool_func.preset_kwargs, **(tool_call.get("input", {}) or {})}
tool_call["input"] 的同名键会覆盖 preset_kwargs——所以模型传的参数优先。
3.2 完整的 Agent 配置
1 | import agentscope |
3.3 不用 API key 的替代验证
用 call_tool_function 直接测试工具调用,跳过模型层:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15async def verify_integration():
setup_demo_db("demo.db")
# 模拟模型返回的 ToolUseBlock
tool_call = ToolUseBlock(
type="tool_use",
id="call_1",
name="query_database",
input={"sql": "SELECT COUNT(*) as total, status FROM users GROUP BY status"},
)
async for response in toolkit.call_tool_function(tool_call):
print("工具结果:", response.content[0]["text"])
asyncio.run(verify_integration())
设计一瞥
设计一瞥:为什么工具函数不直接返回字符串?
如果工具函数只返回str,Toolkit 内部需要把它包装成ToolResponse(content=[TextBlock(text=...)])。AgentScope 选择让工具函数直接返回ToolResponse,这样工具可以返回多模态内容(图片、音频)和流式响应,不需要框架猜测包装方式。
代价:工具函数需要多写几行代码来构造ToolResponse。但call_tool_function(_toolkit.py:853)内部也处理了直接返回字符串的情况,会自动包装——所以实际上两种方式都支持。
完整流程图
1 | sequenceDiagram |
试一试:给工具加一个缓存中间件
这个练习不需要 API key。
目标:给 query_database 加一个简单的缓存中间件,相同 SQL 不重复查询。
步骤:
- 定义缓存中间件:
1 | import hashlib |
- 注册中间件并测试:
1 | toolkit.register_middleware(cache_middleware) |
- 观察输出:第二次调用应该返回缓存的结果。
PR 检查清单
提交一个新 Tool 函数的 PR 时,检查以下项:
- 类型标注:所有参数有类型标注,返回类型明确
- Docstring:按 Google 风格写 Args 和 Returns,框架从中提取 JSON Schema
- ToolResponse:返回
ToolResponse(不是裸字符串或 dict) - 错误处理:异常不暴露内部信息,返回用户友好的错误消息
- 测试:至少覆盖正常路径、错误路径、边界情况
-
__init__.py导出:如果是公共 API,在对应模块的__init__.py中导出 - pre-commit 通过:
pre-commit run --all-files无报错
检查点
你现在理解了:
- 同步工具:定义函数 → 注册到 Toolkit → 自动生成 JSON Schema →
call_tool_function调用 - 流式工具:函数返回
AsyncGenerator[ToolResponse, None],Toolkit 自动处理流式迭代 - preset_kwargs:预设参数不暴露给模型,运行时与工具调用参数合并
- 中间件:可以在工具执行前后插入缓存、日志等逻辑
- ToolResponse 是工具函数的标准返回类型,支持多模态内容和流式
自检练习:
- 如果你的工具函数返回的是
str而不是ToolResponse,call_tool_function会怎么处理?(提示:读_toolkit.py:970附近的返回类型判断逻辑) preset_kwargs={"db_path": "/prod.db"}和模型传入input={"db_path": "/dev.db", "sql": "..."},最终db_path的值是什么?(提示:看合并顺序{**preset, **input})
下一章预告
我们造了一个新 Tool。下一章,我们造一个更复杂的组件——新的 Model Provider。接入一个虚构的 “FastLLM” API,从非流式到流式到结构化输出,三步走。