Tool Use:工具定义、注册与分发
Tool Use:工具定义、注册与分发
从这一章开始,我们会关注一些工程化实现,确保后续工具具有可扩展性。
s02 新增的唯一机制是 工具注册与分发:Agent Loop 不再硬编码调用
run_bash(),而是把模型返回的工具名称和参数交给统一入口 execute_tool()。
新增工具时只需要定义工具并显式注册,不需要修改 Agent Loop。
flowchart LR
accTitle: s02 工具注册与分发流程
accDescr: s01 的 Agent Loop 保持不变;模型请求工具时,s02 新增的分发层根据工具名找到对应实现,执行结果追加到消息历史后再次交给模型。
user["用户提问:messages"] --> llm["大模型:检查 tool_calls"]
llm --> decision{"存在 tool_calls"}
decision -->|否| final(["返回结果"])
decision -->|是| dispatcher
subgraph s02["s02 新增:REGISTRY 分发"]
direction TB
dispatcher["execute_tool"]
dispatcher --> bash_tool["bash"]
dispatcher --> read_tool["read_file"]
dispatcher --> write_tool["write_file"]
dispatcher --> edit_tool["edit_file"]
dispatcher --> glob_tool["glob"]
end
bash_tool --> tool_result["工具结果追加到 messages"]
read_tool --> tool_result
write_tool --> tool_result
edit_tool --> tool_result
glob_tool --> tool_result
tool_result --> llm
class decision attention
class dispatcher,bash_tool,read_tool,write_tool,edit_tool,glob_tool,tool_result added
项目代码
.env
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Chat</title>
<style>
:root {
--ink-950: #171a1f;
--ink-900: #20242a;
--ink-800: #2a3037;
--ink-500: #74808c;
--line: #d8ddd9;
--line-strong: #bcc5c0;
--canvas: #f1f3f0;
--surface: #fbfcfa;
--surface-muted: #f5f7f4;
--accent: #168371;
--accent-strong: #0d6c5d;
--danger: #b83a32;
font-family: "Avenir Next", "Noto Sans SC", "PingFang SC", sans-serif;
color: var(--ink-900);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background:
linear-gradient(rgba(23, 26, 31, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(23, 26, 31, 0.025) 1px, transparent 1px),
var(--canvas);
background-size: 32px 32px;
}
button,
textarea {
font: inherit;
letter-spacing: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-room {
display: grid;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.chat-header {
position: relative;
z-index: 1;
min-height: 66px;
border-bottom: 1px solid #343a41;
background: var(--ink-950);
color: #f2f5f2;
}
.header-inner {
display: flex;
width: min(960px, calc(100% - 40px));
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #485159;
border-radius: 4px;
background: #22272d;
color: #62c4ae;
font: 800 0.85rem "SFMono-Regular", Consolas, monospace;
}
.brand h1 {
margin: 0;
font-size: 1rem;
}
.connection-state {
display: inline-flex;
align-items: center;
gap: 8px;
color: #e28b84;
font: 0.72rem "SFMono-Regular", Consolas, monospace;
}
.connection-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ca554d;
box-shadow: 0 0 0 3px rgba(202, 85, 77, 0.13);
}
.connection-state.online {
color: #8dcabd;
}
.connection-state.online .connection-dot {
background: #53b69f;
box-shadow: 0 0 0 3px rgba(83, 182, 159, 0.13);
}
.chat-workspace {
display: grid;
width: min(960px, 100%);
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
margin: 0 auto;
overflow: hidden;
border-inline: 1px solid var(--line);
background: var(--surface);
}
.messages {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px;
background:
linear-gradient(rgba(23, 26, 31, 0.025) 1px, transparent 1px),
var(--surface-muted);
background-size: 100% 28px;
scrollbar-width: none;
}
.messages::-webkit-scrollbar,
textarea::-webkit-scrollbar {
display: none;
}
.message {
display: flex;
max-width: 78%;
flex: 0 0 auto;
flex-direction: column;
align-self: flex-start;
gap: 3px;
}
.message.user {
align-items: flex-end;
align-self: flex-end;
}
.message-meta {
padding: 0 3px;
color: var(--ink-500);
font: 0.68rem "SFMono-Regular", Consolas, monospace;
}
.message-bubble {
max-width: 100%;
padding: 9px 11px;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
color: var(--ink-800);
font-size: 0.9rem;
line-height: 1.6;
}
.message.user .message-bubble {
border-color: var(--accent-strong);
background: var(--accent);
color: #fff;
white-space: pre-wrap;
}
.message.error .message-bubble {
border-color: var(--danger);
color: var(--danger);
white-space: pre-wrap;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote,
.markdown pre,
.markdown table {
margin: 0 0 10px;
}
.markdown h1,
.markdown h2,
.markdown h3 {
margin: 16px 0 8px;
line-height: 1.35;
}
.markdown h1 { font-size: 1.18rem; }
.markdown h2 { font-size: 1.08rem; }
.markdown h3 { font-size: 1rem; }
.markdown ul,
.markdown ol {
padding-left: 22px;
}
.markdown blockquote {
padding-left: 12px;
border-left: 3px solid var(--line-strong);
color: var(--ink-500);
}
.markdown code {
padding: 2px 4px;
border-radius: 3px;
background: var(--surface-muted);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.84em;
}
.markdown pre {
max-width: 100%;
padding: 12px;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--ink-950);
color: #eef3ef;
}
.markdown pre code {
padding: 0;
background: transparent;
color: inherit;
}
.markdown a {
color: var(--accent-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.markdown th,
.markdown td {
padding: 6px 8px;
border: 1px solid var(--line);
text-align: left;
}
.chat-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: end;
gap: 9px;
padding: 12px 14px max(12px, env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--surface);
}
textarea {
width: 100%;
min-width: 0;
height: 84px;
resize: none;
padding: 10px 11px;
overflow-y: auto;
border: 1px solid var(--line-strong);
border-radius: 4px;
outline: none;
background: #fff;
color: var(--ink-900);
line-height: 1.4;
scrollbar-width: none;
}
textarea:focus {
border-color: var(--accent);
}
.send-button,
.clear-button {
min-width: 74px;
height: 42px;
padding: 0 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 700;
}
.send-button {
border: 1px solid var(--accent-strong);
background: var(--accent);
color: #fff;
}
.send-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.clear-button {
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--ink-800);
}
.clear-button:hover:not(:disabled) {
border-color: var(--ink-500);
background: var(--surface-muted);
}
@media (max-width: 700px) {
.header-inner {
width: calc(100% - 24px);
}
.chat-workspace {
border: 0;
}
.messages {
padding: 14px 12px;
}
.message {
max-width: 88%;
}
.chat-composer {
grid-template-columns: minmax(0, 1fr) 64px 64px;
padding-inline: 10px;
}
.send-button,
.clear-button {
min-width: 0;
padding: 0 8px;
}
}
</style>
</head>
<body>
<div class="chat-room">
<header class="chat-header">
<div class="header-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">Q/</span>
<h1>Agent Chat</h1>
</div>
<div id="connectionState" class="connection-state" role="status">
<span class="connection-dot" aria-hidden="true"></span>
<span id="connectionText">离线</span>
</div>
</div>
</header>
<main class="chat-workspace">
<section
id="messages"
class="messages"
aria-label="消息"
aria-live="polite"
></section>
<form id="composer" class="chat-composer">
<textarea
id="textInput"
aria-label="消息"
placeholder="输入消息"
></textarea>
<button
id="sendButton"
class="send-button"
type="submit"
disabled
>
发送
</button>
<button
id="clearButton"
class="clear-button"
type="button"
disabled
>
清除
</button>
</form>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script>
const API_URL = "/api/chat";
const HEALTH_URL = "/health";
const HEALTH_INTERVAL_MS = 5000;
const HEALTH_TIMEOUT_MS = 3000;
const messagesElement = document.getElementById("messages");
const composer = document.getElementById("composer");
const textInput = document.getElementById("textInput");
const sendButton = document.getElementById("sendButton");
const clearButton = document.getElementById("clearButton");
const connectionState = document.getElementById("connectionState");
const connectionText = document.getElementById("connectionText");
let sending = false;
let checkingHealth = false;
let activeRequest = null;
let displayVersion = 0;
const conversationHistory = [];
marked.setOptions({
gfm: true,
breaks: true,
});
function setOnline(value) {
connectionState.classList.toggle("online", value);
connectionText.textContent = value ? "在线" : "离线";
}
function syncControls() {
sendButton.disabled = sending || !textInput.value.trim();
clearButton.disabled =
!sending &&
messagesElement.childElementCount === 0;
}
function scrollToBottom() {
messagesElement.scrollTop = messagesElement.scrollHeight;
}
function renderMarkdown(element, source) {
const html = marked.parse(source);
element.classList.add("markdown");
element.innerHTML = DOMPurify.sanitize(html);
element.querySelectorAll("a").forEach((link) => {
link.target = "_blank";
link.rel = "noopener noreferrer";
});
}
function appendMessage(role, content, options = {}) {
const wrapper = document.createElement("article");
wrapper.className =
`message ${role}${options.error ? " error" : ""}`;
const meta = document.createElement("div");
meta.className = "message-meta";
const time = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
meta.textContent =
`${role === "user" ? "你" : "Agent"} · ${time}`;
const bubble = document.createElement("div");
bubble.className = "message-bubble";
if (role === "assistant" && options.markdown) {
renderMarkdown(bubble, content);
} else {
bubble.textContent = content;
}
wrapper.append(meta, bubble);
messagesElement.append(wrapper);
scrollToBottom();
syncControls();
return { wrapper, bubble };
}
function extractAnswer(data) {
if (typeof data?.message?.content === "string") {
return data.message.content;
}
if (typeof data?.content === "string") {
return data.content;
}
if (typeof data === "string") {
return data;
}
throw new Error("接口没有返回有效的消息内容");
}
async function checkHealth() {
if (checkingHealth) {
return;
}
checkingHealth = true;
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
HEALTH_TIMEOUT_MS,
);
try {
const response = await fetch(HEALTH_URL, {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
setOnline(response.ok);
} catch {
setOnline(false);
} finally {
window.clearTimeout(timeout);
checkingHealth = false;
}
}
function clearMessages() {
displayVersion += 1;
if (activeRequest) {
activeRequest.abort();
activeRequest = null;
}
sending = false;
conversationHistory.length = 0;
messagesElement.replaceChildren();
syncControls();
textInput.focus();
}
async function sendMessage() {
const content = textInput.value.trim();
if (!content || sending) {
return;
}
const requestVersion = displayVersion;
const controller = new AbortController();
activeRequest = controller;
sending = true;
appendMessage("user", content);
const userMessage = { role: "user", content };
conversationHistory.push(userMessage);
textInput.value = "";
syncControls();
const pending = appendMessage("assistant", "正在思考…");
try {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: conversationHistory,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`);
}
const data = await response.json();
if (requestVersion !== displayVersion) {
return;
}
const answer = extractAnswer(data);
conversationHistory.push({
role: "assistant",
content: answer,
});
renderMarkdown(pending.bubble, answer);
} catch (error) {
if (
error.name === "AbortError" ||
requestVersion !== displayVersion
) {
return;
}
if (conversationHistory.at(-1) === userMessage) {
conversationHistory.pop();
}
pending.wrapper.classList.add("error");
pending.bubble.classList.remove("markdown");
pending.bubble.textContent =
error instanceof Error
? error.message
: "请求失败";
checkHealth();
} finally {
if (requestVersion === displayVersion) {
sending = false;
activeRequest = null;
syncControls();
scrollToBottom();
textInput.focus();
}
}
}
textInput.addEventListener("input", syncControls);
textInput.addEventListener("keydown", (event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.isComposing
) {
event.preventDefault();
sendMessage();
}
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
sendMessage();
});
clearButton.addEventListener("click", clearMessages);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
checkHealth();
}
});
checkHealth();
window.setInterval(checkHealth, HEALTH_INTERVAL_MS);
syncControls();
textInput.focus();
</script>
</body>
</html>
"""s02 的可选 FastAPI 示例:复用 Agent Loop,不保存服务端会话。
在 s02-tool-use/ 目录运行:
uv run --project .. uvicorn examples.web.app:app --port 8100
前端契约(见 agent-chat.html):
POST /api/chat {"messages": [{"role": "user", "content": "..."}]}
-> {"message": {"role": "assistant", "content": "..."}}
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from fastapi import FastAPI
from fastapi.responses import FileResponse
from pydantic import BaseModel
from main import SYSTEM, agent_loop
HTML_FILE = Path(__file__).with_name("agent-chat.html")
app = FastAPI()
class ChatMessage(BaseModel):
role: Literal["user", "assistant"]
content: str
class ChatRequest(BaseModel):
messages: list[ChatMessage]
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.post("/api/chat")
async def chat(payload: ChatRequest) -> dict:
messages = [
{"role": "system", "content": SYSTEM},
*(message.model_dump() for message in payload.messages),
]
await agent_loop(messages)
answer = messages[-1].get("content") or ""
return {"message": {"role": "assistant", "content": answer}}
@app.get("/")
async def index() -> FileResponse:
return FileResponse(HTML_FILE, media_type="text/html")
"""s02: 多工具注册与分发。
相对 s01,Agent Loop 的形状不变;本章只把硬编码的 bash 调用替换为工具分发器,
并增加 read_file、write_file、edit_file 和 glob。
运行:
在 agentic-s02-tool-use/.env 配置 MODEL_API_KEY、BASE_URL,可选 MODEL
uv run agentic-s02-tool-use/main.py
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import AsyncOpenAI
from tools import TOOLS, WORKDIR, execute_tool
ENV_FILE = Path(__file__).resolve().parent / ".env"
load_dotenv(ENV_FILE)
client = AsyncOpenAI(api_key=os.getenv("MODEL_API_KEY"), base_url=os.getenv("BASE_URL"))
MODEL = os.getenv("MODEL", "doubao-seed-evolving")
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use tools to solve tasks. Act, don't explain."
)
# 循环调用工具,直到模型不再请求工具
async def agent_loop(messages: list) -> None:
while True:
response = await client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto"
)
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True))
tool_calls = message.tool_calls or []
if not tool_calls: # 模型没有调用工具,本轮结束
return
for call in tool_calls:
print(f"\033[33m[tool] {call.function.name} {call.function.arguments}\033[0m")
output = await execute_tool(
call.function.name,
call.function.arguments,
)
print(output[:200])
messages.append(
{"role": "tool", "tool_call_id": call.id, "content": output}
)
# 入口
async def main() -> None:
print("s02: Tool Use")
print("输入问题回车发送,输入 q 退出。\n")
messages = [{"role": "system", "content": SYSTEM}]
while True:
try:
query = input("s02 >> ")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
messages.append({"role": "user", "content": query})
await agent_loop(messages)
print(messages[-1].get("content") or "")
print()
if __name__ == "__main__":
asyncio.run(main())
# s02: Tool Use
本章在 s01 Agent Loop 的基础上增加多工具注册与分发。
核心原则:
> 新增工具只扩展工具目录,不修改 Agent Loop。
## 相对 s01 的变化
| 内容 | s01 | s02 |
|---|---|---|
| 工具数量 | 仅 `bash` | `bash`、`read_file`、`write_file`、`edit_file`、`glob` |
| 执行方式 | 硬编码调用 `run_bash()` | 根据工具名称统一分发 |
| 文件边界 | 无专用文件工具 | 文件工具只能访问当前章节目录 |
| Agent Loop | 基础循环 | 循环形状保持不变 |
上游使用 Anthropic tool block;本实现使用 `AsyncOpenAI` 和
OpenAI-compatible Chat Completions function tools。
## 目录结构
```text
s02-tool-use/
├── README.md
├── main.py
├── tools/
│ ├── __init__.py
│ ├── contract.py
│ ├── registry.py
│ ├── workspace.py
│ ├── shell.py
│ └── filesystem.py
├── tests/
│ ├── test_tools.py
│ └── test_web.py
└── examples/
└── web/
├── app.py
└── agent-chat.html
```
- `contract.py`:定义一个工具包含哪些信息。
- `registry.py`:显式注册工具、生成 OpenAI schema,并分发 tool call。
- `workspace.py`:统一管理工作目录和文件路径边界。
- `shell.py`:Shell 能力域,目前提供 `bash`。
- `filesystem.py`:文件系统能力域,提供读、写、编辑和 glob。
- `__init__.py`:只暴露 Agent Loop 需要的公共接口。
- `examples/web/`:可选 Web 适配示例,不属于 s02 核心路径。
Agent Loop 只依赖:
```python
from tools import TOOLS, execute_tool
```
新增工具时:
1. 在对应能力域模块中实现 handler 和 `ToolDefinition`。
2. 在 `registry.py` 的 `TOOL_DEFINITIONS` 中显式启用。
例如新增文件搜索工具 `grep`:
```text
tools/filesystem.py # 实现 GREP
tools/registry.py # 加入 GREP
```
Agent Loop 不需要修改。这里有意使用显式注册而不是自动扫描:
一个工具只有被加入 registry 后才会暴露给模型。
## 工作目录
文件工具和 bash 的工作目录固定为 `s02-tool-use/`,与启动命令所在目录无关。
`read_file`、`write_file`、`edit_file` 和 `glob` 会拒绝逃出该目录的路径。
当前 bash 黑名单只是教学占位,不构成安全边界。正式权限治理属于 s03。
## 运行
在项目根目录执行:
```bash
uv run s02-tool-use/main.py
```
环境变量:
```text
MODEL_API_KEY
BASE_URL
MODEL # 可选
```
## 测试
```bash
cd s02-tool-use
uv run --project .. python -m unittest discover -s tests -v
```
## Web 示例
HTML 保持单文件,Web 适配层与章节主线隔离在 `examples/web/`。
对话历史由浏览器页面内存维护,每次请求发送完整的 `user/assistant` 消息列表;
FastAPI 不保存 Session。刷新页面或点击清除会丢弃历史。
```bash
cd s02-tool-use
uv run --project .. uvicorn examples.web.app:app --port 8100
```
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from tools import TOOLS, WORKDIR, execute_tool
from tools.filesystem import EDIT_FILE, GLOB, READ_FILE, WRITE_FILE
from tools.registry import TOOL_DEFINITIONS
from tools.shell import TOOL as BASH
class ToolRuntimeTests(unittest.IsolatedAsyncioTestCase):
def test_registry_explicitly_registers_each_tool_group(self) -> None:
self.assertEqual(
TOOL_DEFINITIONS,
[BASH, READ_FILE, WRITE_FILE, EDIT_FILE, GLOB],
)
def test_all_five_tools_are_exposed(self) -> None:
names = {tool["function"]["name"] for tool in TOOLS}
self.assertEqual(
names,
{"bash", "read_file", "write_file", "edit_file", "glob"},
)
def test_workdir_is_the_chapter_directory(self) -> None:
chapter_dir = Path(__file__).resolve().parents[1]
self.assertEqual(WORKDIR, chapter_dir)
async def test_unknown_tool_and_invalid_json_are_rejected(self) -> None:
unknown = await execute_tool("unknown", "{}")
invalid_json = await execute_tool("bash", "not-json")
self.assertTrue(unknown.startswith("Error: Unknown tool"))
self.assertTrue(invalid_json.startswith("Error: Invalid arguments"))
async def test_file_tool_flow(self) -> None:
with tempfile.TemporaryDirectory(dir=WORKDIR) as temp_dir:
relative_dir = Path(temp_dir).relative_to(WORKDIR)
relative_file = relative_dir / "nested" / "demo.txt"
write_result = await self.call_tool(
"write_file",
{"path": str(relative_file), "content": "one\ntwo\nthree\n"},
)
self.assertTrue(write_result.startswith("Wrote"))
read_result = await self.call_tool(
"read_file",
{"path": str(relative_file), "limit": 2},
)
self.assertEqual(read_result, "one\ntwo\n... (1 more lines)")
edit_result = await self.call_tool(
"edit_file",
{
"path": str(relative_file),
"old_text": "two",
"new_text": "TWO",
},
)
self.assertTrue(edit_result.startswith("Edited"))
glob_result = await self.call_tool(
"glob",
{"pattern": f"{relative_dir}/**/*.txt"},
)
self.assertIn(str(relative_file), glob_result)
async def test_file_tools_cannot_escape_workdir(self) -> None:
result = await self.call_tool(
"read_file",
{"path": "../outside.txt"},
)
self.assertTrue(result.startswith("Error: Path escapes workspace"))
@staticmethod
async def call_tool(name: str, arguments: dict[str, object]) -> str:
return await execute_tool(name, json.dumps(arguments))
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import unittest
from unittest.mock import patch
from pydantic import ValidationError
from examples.web import app as web_app
class WebHistoryTests(unittest.IsolatedAsyncioTestCase):
async def test_complete_client_history_is_sent_to_agent_loop(self) -> None:
received_messages: list[dict[str, str]] = []
async def fake_agent_loop(messages: list[dict[str, str]]) -> None:
received_messages.extend(messages)
messages.append({"role": "assistant", "content": "第二轮回答"})
payload = web_app.ChatRequest(
messages=[
{"role": "user", "content": "第一句话"},
{"role": "assistant", "content": "第一轮回答"},
{"role": "user", "content": "我上一句说了什么?"},
]
)
with patch.object(web_app, "agent_loop", new=fake_agent_loop):
response = await web_app.chat(payload)
self.assertEqual(
[message["role"] for message in received_messages],
["system", "user", "assistant", "user"],
)
self.assertEqual(received_messages[1]["content"], "第一句话")
self.assertEqual(
response,
{"message": {"role": "assistant", "content": "第二轮回答"}},
)
def test_request_has_no_server_session_field(self) -> None:
self.assertNotIn("session_id", web_app.ChatRequest.model_fields)
def test_old_single_message_payload_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
web_app.ChatRequest.model_validate(
{"messages": {"role": "user", "content": "旧请求格式"}}
)
if __name__ == "__main__":
unittest.main()
"""工具包的公共接口"""
from .registry import TOOLS, execute_tool
from .workspace import WORKDIR
__all__ = ["TOOLS", "WORKDIR", "execute_tool"]
"""工具层的公共契约。
定义工具名称、说明、参数、参数格式、执行函数。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
# handler 接收已经解析过的 JSON 参数,返回给模型的文本结果。
ToolHandler = Callable[[dict[str, Any]], Awaitable[str]]
@dataclass(frozen=True)
class ToolDefinition:
"""工具定义
name / description / parameters 转换为 OpenAI tool schema
handler 只保留在本地,用于执行工具。
"""
name: str
description: str
parameters: dict[str, Any]
handler: ToolHandler
"""文件系统工具:read_file、write_file、edit_file 和 glob。"""
from __future__ import annotations
import asyncio
import glob as glob_module
from typing import Any
from .contract import ToolDefinition
from .workspace import WORKDIR, safe_path
def _read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
if limit is not None and limit < len(lines):
omitted = len(lines) - limit
lines = lines[:limit] + [f"... ({omitted} more lines)"]
return "\n".join(lines)
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def read_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
limit = arguments.get("limit")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if limit is not None and (
not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0
):
return "Error: limit must be a positive integer"
return await asyncio.to_thread(_read, path, limit)
def _write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} characters to {path}"
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def write_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
content = arguments.get("content")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if not isinstance(content, str):
return "Error: content must be a string"
return await asyncio.to_thread(_write, path, content)
def _edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text(encoding="utf-8")
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(
text.replace(old_text, new_text, 1),
encoding="utf-8",
)
return f"Edited {path}"
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def edit_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
old_text = arguments.get("old_text")
new_text = arguments.get("new_text")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if not isinstance(old_text, str) or not old_text:
return "Error: old_text must be a non-empty string"
if not isinstance(new_text, str):
return "Error: new_text must be a string"
return await asyncio.to_thread(_edit, path, old_text, new_text)
def _glob(pattern: str) -> str:
try:
matches = sorted(
{
match
for match in glob_module.glob(
pattern,
root_dir=WORKDIR,
recursive=True,
)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
}
)
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except (OSError, ValueError) as exc:
return f"Error: {exc}"
async def glob(arguments: dict[str, Any]) -> str:
pattern = arguments.get("pattern")
if not isinstance(pattern, str) or not pattern:
return "Error: pattern must be a non-empty string"
return await asyncio.to_thread(_glob, pattern)
READ_FILE = ToolDefinition(
name="read_file",
description="Read UTF-8 file contents.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer", "minimum": 1},
},
"required": ["path"],
"additionalProperties": False,
},
handler=read_file,
)
WRITE_FILE = ToolDefinition(
name="write_file",
description="Write UTF-8 content to a file.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
handler=write_file,
)
EDIT_FILE = ToolDefinition(
name="edit_file",
description="Replace the first exact text occurrence in a file.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string", "minLength": 1},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
"additionalProperties": False,
},
handler=edit_file,
)
GLOB = ToolDefinition(
name="glob",
description="Find files matching a glob pattern; ** matches recursively.",
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string"},
},
"required": ["pattern"],
"additionalProperties": False,
},
handler=glob,
)
TOOLS = [READ_FILE, WRITE_FILE, EDIT_FILE, GLOB]
"""工具注册与分发。
具体实现按能力域放在 shell.py 和 filesystem.py;本模块只决定启用哪些工具,
并把这些定义转换为 OpenAI function tools。
"""
from __future__ import annotations
import json
from openai.types.chat import ChatCompletionToolParam
from .contract import ToolDefinition
from .filesystem import TOOLS as FILESYSTEM_TOOLS
from .shell import TOOL as BASH
TOOL_DEFINITIONS = [BASH, *FILESYSTEM_TOOLS]
def _build_registry(
definitions: list[ToolDefinition],
) -> dict[str, ToolDefinition]:
"""按工具名建立索引,并在启动时拒绝重复名称。"""
registry: dict[str, ToolDefinition] = {}
for definition in definitions:
if definition.name in registry:
raise ValueError(f"Duplicate tool name: {definition.name}")
registry[definition.name] = definition
return registry
REGISTRY = _build_registry(TOOL_DEFINITIONS)
# 只有 schema 会发送给模型,本地 handler 不会暴露。
TOOLS: list[ChatCompletionToolParam] = [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
},
}
for tool in TOOL_DEFINITIONS
]
async def execute_tool(name: str, raw_arguments: str) -> str:
"""解析 OpenAI 返回的 JSON 参数并调用对应工具。"""
tool = REGISTRY.get(name)
if tool is None:
return f"Error: Unknown tool: {name}"
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError as exc:
return f"Error: Invalid arguments: {exc}"
if not isinstance(arguments, dict):
return "Error: arguments must be an object"
return await tool.handler(arguments)
"""bash 工具。"""
from __future__ import annotations
import asyncio
import subprocess
from typing import Any
from .contract import ToolDefinition
from .workspace import WORKDIR
def _run(command: str) -> str:
"""同步执行命令;字符串黑名单仅是 s02 教学占位。"""
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(value in command for value in dangerous):
return "Error: Dangerous command blocked"
try:
result = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
errors="replace",
timeout=120,
)
output = (result.stdout + result.stderr).strip()
return output[:50_000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except OSError as exc:
return f"Error: {exc}"
async def handler(arguments: dict[str, Any]) -> str:
command = arguments.get("command")
if not isinstance(command, str):
return "Error: command must be a string"
return await asyncio.to_thread(_run, command)
TOOL = ToolDefinition(
name="bash",
description="Run a shell command.",
parameters={
"type": "object",
"properties": {
"command": {"type": "string"},
},
"required": ["command"],
"additionalProperties": False,
},
handler=handler,
)
"""工具共享的工作目录边界。"""
from pathlib import Path
# 固定为 s02-tool-use,避免启动位置改变工具作用范围。
WORKDIR = Path(__file__).resolve().parents[1]
def safe_path(path: str) -> Path:
"""解析工作目录内的路径,拒绝 ../ 和符号链接导致的目录逃逸。"""
resolved = (WORKDIR / path).resolve()
if not resolved.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {path}")
return resolved
目录结构
s02-tool-use/ ├── s02.md # 本章学习笔记 ├── README.md # 运行说明 ├── main.py # Agent Loop 和命令行入口 ├── tools/ │ ├── __init__.py # 工具包公共接口 │ ├── contract.py # 工具定义的数据结构与类型契约 │ ├── registry.py # 工具注册、OpenAI Schema 生成与调用分发 │ ├── workspace.py # 工作目录配置与安全路径解析 │ ├── shell.py # bash 命令工具 │ └── filesystem.py # 文件读取、写入、编辑和 glob 工具 ├── tests/ │ ├── test_tools.py │ └── test_web.py └── examples/ └── web/ ├── app.py # FastAPI Web 适配示例 └── agent-chat.html # 单文件聊天前端
下面逐一看 tools 下的内容。
init
__init__.py 负责把内部模块的三个对象暴露出去:
TOOLS:符合 OpenAI function calling 格式的工具 schema 列表,可以直接传给模型。execute_tool:统一工具执行入口,根据模型返回的名称和参数找到工具并执行。WORKDIR:工具使用的工作目录,固定为章节根目录s02-tool-use/。
"""工具包的公共接口。""" from .registry import TOOLS, execute_tool from .workspace import WORKDIR __all__ = ["TOOLS", "WORKDIR", "execute_tool"]
这样 Agent Loop 只依赖工具包的公共接口,不需要知道具体工具位于哪个模块。
contract
contract.py 规定“一个工具到底长什么样”。
from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any ToolHandler = Callable[[dict[str, Any]], Awaitable[str]] @dataclass(frozen=True) class ToolDefinition: name: str description: str parameters: dict[str, Any] handler: ToolHandler
ToolHandler 有三个约束:
- 入参是
dict[str, Any]。模型返回的 JSON 字符串由registry.execute_tool()负责解析。 - handler 是异步函数,返回
Awaitable[str]。同步阻塞操作仍需显式使用asyncio.to_thread(),仅声明为async不会自动消除阻塞。 - 返回值是字符串,可以直接作为
role="tool"消息的内容。可恢复的输入或 I/O 错误返回Error: ...;编程错误不应被无差别吞掉。
@dataclass(frozen=True) 表示工具定义创建后不可修改,避免运行期间 schema 与
handler 的对应关系发生变化。
整个数据流是“同一对象,两种用途”:
flowchart LR accTitle: ToolDefinition 的两种用途 accDescr: 工具定义中的名称、描述和参数被转换为 OpenAI schema,handler 则进入本地注册表用于执行。 definition["ToolDefinition"] definition --> public["name / description / parameters"] definition --> handler["handler"] public --> schema["OpenAI tool schema"] schema --> model["发送给模型"] handler --> registry["REGISTRY[name]"] registry --> execute["本地执行"] class schema,registry added class handler attention
模型只会收到 name、description 和 parameters。handler 不属于 API
schema,只保留在本地注册表中。
第一步:注册
TOOL_DEFINITIONS = [BASH, *FILESYSTEM_TOOLS] REGISTRY = _build_registry(TOOL_DEFINITIONS)
_build_registry() 按名称建立索引,并在启动时拒绝重名。
第二步:生成公开 schema
TOOLS: list[ChatCompletionToolParam] = [ { "type": "function", "function": { "name": tool.name, "description": tool.description, "parameters": tool.parameters, }, } for tool in TOOL_DEFINITIONS ]
这里只提取需要发送给模型的三个字段。TOOLS 最终由 main.py 传给模型。
第三步:分发执行
async def execute_tool(name: str, raw_arguments: str) -> str: tool = REGISTRY.get(name) ... arguments = json.loads(raw_arguments) ... return await tool.handler(arguments)
模型返回 tool_calls 后,Agent Loop 将工具名和原始参数交给
execute_tool()。分发器找到对应 ToolDefinition,解析参数并执行 handler,
然后 Agent Loop 把结果追加为 role="tool" 消息。
registry
registry.py 负责三件事:
- 收集当前启用的工具定义。
- 转换出 OpenAI API 需要的 function tool schema。
- 根据模型返回的工具名称查找并执行 handler。
from .filesystem import TOOLS as FILESYSTEM_TOOLS from .shell import TOOL as BASH TOOL_DEFINITIONS = [BASH, *FILESYSTEM_TOOLS]
这里采用显式注册,不使用自动扫描。文件中存在一个工具,不代表它会自动暴露给
模型;只有进入 TOOL_DEFINITIONS 的工具才会被启用。
注册表按名称建立索引:
def _build_registry( definitions: list[ToolDefinition], ) -> dict[str, ToolDefinition]: registry: dict[str, ToolDefinition] = {} for definition in definitions: if definition.name in registry: raise ValueError(f"Duplicate tool name: {definition.name}") registry[definition.name] = definition return registry
统一分发入口处理模型输入边界:
async def execute_tool(name: str, raw_arguments: str) -> str: tool = REGISTRY.get(name) if tool is None: return f"Error: Unknown tool: {name}" try: arguments = json.loads(raw_arguments) except json.JSONDecodeError as exc: return f"Error: Invalid arguments: {exc}" if not isinstance(arguments, dict): return "Error: arguments must be an object" return await tool.handler(arguments)
未知工具、非法 JSON 和非对象参数属于可恢复的模型输入错误,因此将错误文本返回 给模型,让模型根据结果调整下一步。
一次完整调用的数据流如下:
flowchart LR
accTitle: 工具调用的注册表分发流程
accDescr: 模型返回工具名称和 JSON 参数,分发器查询注册表并解析参数,调用对应 handler 后把文本结果回传模型。
tool_request["tool_call: name + arguments"]
tool_request --> lookup{"REGISTRY 中存在"}
lookup -->|否| unknown["Error: Unknown tool"]
lookup -->|是| parse{"JSON 是对象"}
parse -->|否| invalid["Error: Invalid arguments"]
parse -->|是| handler["await tool.handler"]
handler --> output["stdout / stderr 或工具结果"]
unknown --> result["role=tool"]
invalid --> result
output --> result
result --> model["模型继续推理"]
class handler,output,result added
class lookup,parse,unknown,invalid attention
workspace
workspace.py 统一维护工作目录和文件路径边界:
WORKDIR = Path(__file__).resolve().parents[1] def safe_path(path: str) -> Path: resolved = (WORKDIR / path).resolve() if not resolved.is_relative_to(WORKDIR): raise ValueError(f"Path escapes workspace: {path}") return resolved
WORKDIR 固定为章节根目录,所以从仓库根目录或章节目录启动,工具作用范围都不会
变化。safe_path() 在路径解析后检查归属,可以阻止 ../ 和符号链接造成的目录
逃逸。
这个限制只覆盖文件工具,不约束 Bash。
shell
shell.py 定义 Bash 工具的 schema 和 handler。subprocess.run() 是同步阻塞
调用,因此通过 asyncio.to_thread() 执行,避免阻塞 Agent 的事件循环:
async def handler(arguments: dict[str, Any]) -> str: command = arguments.get("command") if not isinstance(command, str): return "Error: command must be a string" return await asyncio.to_thread(_run, command)
当前字符串黑名单只能拦截少量明显危险命令,不构成权限系统。正式权限治理属于 s03。
filesystem
filesystem.py 提供四个工具:
| 工具 | 作用 |
|---|---|
read_file | 读取 UTF-8 文件,可限制返回行数 |
write_file | 写入文件,并按需创建父目录 |
edit_file | 精确替换第一次出现的文本 |
glob | 按 glob 模式查找文件,** 表示递归 |
每个工具都包含 handler 和对应的 ToolDefinition。handler 先检查参数,再通过
asyncio.to_thread() 执行同步文件操作。
文件工具统一使用 safe_path(),但 JSON Schema 不能替代运行时校验。模型生成的
参数仍是不可信输入。