从零构建 Agent 框架(一)写出对话循环与工具调用

关于本教程: 这是「从零构建 Agent 框架」系列的第一篇。实现思路参考 czl9707/build-your-own-openclaw 的前两步,但代码由我重新组织为一个带工作目录限制的最小示例。它适合用来学习循环和工具调用,不是生产环境的安全方案。

很多 Agent 框架看起来很复杂:会读文件、改代码、跑测试,还能在任务失败后继续尝试。但如果先把外围能力拿掉,最小实现只需要四样东西:模型、消息历史、工具定义,以及一个负责重复调用它们的循环。

这一篇会实现一个终端 Agent。它能在 workspace/ 内列文件、读文件、写文件,还能运行固定的测试命令。这里刻意不提供任意 bash 工具,因为“模型能生成命令”和“模型应该获得任意命令权限”是两件事。

先看完成后的结构

1
2
3
4
5
6
minimal-agent/
├── agent.py # 循环:请求模型、执行工具、再请求模型
├── llm.py # 模型调用与能力检查
├── tools.py # 工具 schema 与受限执行函数
├── main.py # 终端入口
└── workspace/ # Agent 唯一允许读写的目录

准备 Python 3.10 及以上版本,然后在空目录执行:

1
2
3
4
5
6
7
8
9
python -m venv .venv
source .venv/bin/activate
pip install litellm pytest
mkdir workspace
printf '# Demo project\n' > workspace/README.md

# 不要把 API key 写进 Python 文件,也不要提交到 Git。
export OPENAI_API_KEY='你的密钥'
export MODEL='openai/gpt-4o-mini'

这里的 LiteLLM 是一层薄封装:它把 OpenAI、Anthropic、Gemini 等几十家提供商的接口统一成同一套 completion() 调用,这样换模型时改一个环境变量就够了,不用重写请求代码。你也可以直接用各家官方 SDK,本文选它只是为了让「换模型」这件事更简单。

锁定版本。 LiteLLM 迭代很快,返回结构和函数签名偶尔会变。本文代码在 litellm 1.x 下写成并跑通;建议装完后立刻用 pip freeze > requirements.txt 记下确切版本,半年后照着复现才不会因为接口变动而报错。

本文代码固定以 OpenAI 模型为例。换模型提供商时,除了修改 MODEL,还要把 check_config() 中的环境变量检查改成对应提供商的凭据,并确认该模型支持 tool calling。LiteLLM 提供了 supports_function_calling() 用于检查这件事。LiteLLM Function Calling 文档

一、对话循环:模型不会替你保存历史

调用模型 API 时,服务端通常不会替你的程序记住上一轮对话。程序要把已经发生过的消息保存在 messages 中,再在下一次请求时一起传给模型。

在写代码前,先看清 messages 到底是什么:它是一个列表,每个元素是一条消息字典,最基本的三种角色是:

1
2
3
4
5
messages = [
{"role": "system", "content": "你是一个编程助手。"}, # 一次性的行为设定
{"role": "user", "content": "帮我读一下 README.md"}, # 用户说的话
{"role": "assistant", "content": "好的,我来读取文件。"}, # 模型的回复
]

模型的「记忆」就是这个列表本身:每多聊一轮,你就往列表尾部追加。等引入工具后,还会出现第四种角色 tool,结构稍复杂,我们到第三节看到真实数据时再展开。现在只需记住一件事:历史是你自己维护的一个列表,不是模型端的状态。

先写 llm.py。这个文件只做两件事:读取本地环境变量,调用模型。

关于 async 下面的代码用了 async def / await,这是 Python 的异步写法。本篇其实每一步都是顺序执行、彼此等待,async 并不会让它更快;我们提前用它,只是为了后续几篇要「同时发起多个工具调用 / 多个模型请求」时不必推翻重写。如果你还不熟悉 async,可以先把它当成「函数前面要加 async、调用前面要加 await」的固定写法,不影响理解本篇的主线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# llm.py
import os
import litellm
from litellm import acompletion

MODEL = os.getenv("MODEL", "openai/gpt-4o-mini")


def check_config():
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError("请先设置 OPENAI_API_KEY 环境变量")
if not litellm.supports_function_calling(model=MODEL):
raise RuntimeError(f"{MODEL} 不支持 function calling,请换一个模型")


async def call_llm(messages, tools=None):
response = await acompletion(
model=MODEL,
messages=messages,
tools=tools,
tool_choice="auto",
)
return response.choices[0].message

再写一个只会聊天的 main.py,注意这是一个临时试验版,第三节接上工具后会被完整版覆盖,现在只用它验证「历史增长」这条数据流。此时它没有工具,只能把回复写回历史:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import asyncio
from llm import call_llm, check_config


async def main():
check_config()
messages = [{"role": "system", "content": "你是一个编程助手。"}]

while True:
user = input("你: ").strip()
if user in {"quit", "exit", "q"}:
break

messages.append({"role": "user", "content": user})
message = await call_llm(messages)
messages.append(message.model_dump(exclude_none=True))
print(f"助手: {message.content}\n")


asyncio.run(main())

这里有个容易被略过的细节:message.model_dump(exclude_none=True)call_llm 返回的 message 不是普通字典,而是 LiteLLM 的一个 pydantic 对象;model_dump() 把它转回字典好塞进 messagesexclude_none=True 也不是可有可无:模型回复里常带一堆值为 None 的字段(比如没有工具调用时的 tool_calls),有些提供商在下一次请求里收到 content: null 这类字段会直接报错。去掉空字段,是为了让这条历史能安全地再发回去。

这段程序能连续对话,是因为 messages 在增长,不是因为模型本身拥有长期记忆。它也埋下了一个后续问题:历史越长,请求越贵,最终还会碰到上下文窗口。系列后面会再实现持久化和压缩;现在先把这条数据流看清楚。

二、工具由说明书和执行函数组成

模型不能直接调用你的 Python 函数。你需要先把工具的名字、用途和参数结构告诉模型,这份描述就是 tool schema。模型根据用户任务选择工具,再返回工具名和 JSON 参数;程序负责校验并真正执行。

一个 tool schema 单独拿出来长这样,它就是一段 JSON,用 JSON Schema 描述这个工具收哪些参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取 workspace 中的 UTF-8 文本文件",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对文件路径"}
},
"required": ["path"],
"additionalProperties": false
}
}
}

description 不是给人看的注释,而是模型判断「什么时候该用这个工具」的唯一依据,写得含糊,模型就会用错或不用。这一点后面排错时会再回到。

下面的 tools.py 只提供四个能力:列文件、读文件、写文件和运行测试。前三个工具的路径会被限制在 workspace/ 内,最后一个工具只运行固定的 python -m pytest -q,而不是接受模型拼出的任意 shell 命令。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# tools.py
import asyncio
import json
import os
import sys
from pathlib import Path

WORKSPACE = Path(os.getenv("AGENT_WORKSPACE", "workspace")).resolve()
MAX_FILE_CHARS = 20_000
MAX_FILE_BYTES = 256 * 1024
MAX_OUTPUT_CHARS = 12_000
SKIP_DIR_NAMES = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache"}
TOOLS = {}


def workspace_path(path: str) -> Path:
requested = Path(path)
if requested.is_absolute():
raise ValueError("只允许 workspace 内的相对路径")

target = (WORKSPACE / requested).resolve()
if not target.is_relative_to(WORKSPACE):
raise ValueError("路径不能离开 workspace")
return target


def register(name, description, parameters):
def decorator(func):
TOOLS[name] = (
{
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
},
func,
)
return func
return decorator


@register("list_files", "列出 workspace 中某个目录的文件", {
"type": "object",
"properties": {"path": {"type": "string", "description": "相对目录,默认 ."}},
"required": [],
"additionalProperties": False,
})
async def list_files(path="."):
directory = workspace_path(path)
if not directory.is_dir():
return f"不是目录: {path}"
items = sorted(
p.relative_to(WORKSPACE).as_posix()
for p in directory.iterdir()
if p.name not in SKIP_DIR_NAMES
)
return "\n".join(items[:100]) or "目录为空"


@register("read_file", "读取 workspace 中的 UTF-8 文本文件", {
"type": "object",
"properties": {"path": {"type": "string", "description": "相对文件路径"}},
"required": ["path"],
"additionalProperties": False,
})
async def read_file(path):
file_path = workspace_path(path)
if not file_path.is_file():
return f"文件不存在: {path}"
if file_path.stat().st_size > MAX_FILE_BYTES:
return f"文件过大,拒绝完整读取: {path}(上限 {MAX_FILE_BYTES} bytes)"
with file_path.open("rb") as file:
if b"\x00" in file.read(1024):
return f"疑似二进制文件,拒绝读取: {path}"
text = file_path.read_text(encoding="utf-8")
if len(text) > MAX_FILE_CHARS:
return text[:MAX_FILE_CHARS] + "\n[内容已截断]"
return text


@register("write_file", "在 workspace 中创建或覆盖一个 UTF-8 文本文件", {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对文件路径"},
"content": {"type": "string", "description": "要写入的文本"},
},
"required": ["path", "content"],
"additionalProperties": False,
})
async def write_file(path, content):
file_path = workspace_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"已写入: {file_path.relative_to(WORKSPACE)}"


@register("run_tests", "在 workspace 中运行固定的 pytest 测试命令", {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
})
async def run_tests():
process = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pytest", "-q",
cwd=WORKSPACE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
output, _ = await asyncio.wait_for(process.communicate(), timeout=30)
except asyncio.TimeoutError:
process.kill()
await process.communicate()
return "测试超过 30 秒,已终止"

text = output.decode("utf-8", errors="replace")
return f"退出码: {process.returncode}\n{text[:MAX_OUTPUT_CHARS]}"


def tool_schemas():
return [schema for schema, _ in TOOLS.values()]


async def execute_tool(name, raw_arguments):
if name not in TOOLS:
return f"未知工具: {name}"

try:
arguments = json.loads(raw_arguments or "{}")
if not isinstance(arguments, dict):
return "工具参数必须是 JSON 对象"
except json.JSONDecodeError as error:
return f"工具参数不是合法 JSON: {error}"

_, function = TOOLS[name]
try:
return str(await function(**arguments))
except Exception as error:
return f"工具执行失败: {type(error).__name__}: {error}"

这里特意保留了一个教学上的缺口:write_file 仍是直接覆盖,它只证明 Agent 能写文件,不证明这种编辑可靠。真正的代码编辑至少还要有「先读后改、展示 diff、确认读取后文件没有被外部修改、替换目标唯一」几道门;下一阶段再把它做成安全编辑工具。与之相对,read_file 现在先检查字节数和二进制特征,list_files 也跳过最常见的依赖与缓存目录。仅靠“读完再截断字符”并不够,因为超大文件已经被整个读进内存了。

这段代码里有两个初学者容易卡住的地方,先说清楚:

先说 register 装饰器在做什么。它只是个「登记表」:每定义一个工具函数,就把「这个函数」和「它的 schema」成对存进全局字典 TOOLS。这样后面 tool_schemas() 能一次性取出所有 schema 发给模型,execute_tool() 能按名字反查到真正要执行的函数。如果你觉得装饰器绕,完全可以先在脑子里把它翻译成一句话:TOOLS["read_file"] = (schema, read_file 函数)。装饰器只是把这句登记自动化了,换成手写一个列表也不影响理解。

再说模型传回来的参数是一段 JSON 字符串,不是 dict。这就是 execute_tool() 里要先 json.loads(raw_arguments) 的原因:模型说「我要调用 read_file,参数是 {"path": "README.md"}」,但这串东西到你手里时是文本,你得先解析、再校验它确实是个对象,才能 function(**arguments) 展开成关键字参数去调用。模型偶尔会吐出不合法的 JSON,所以这里用 try/except 兜住,把错误当成工具结果返回给模型而不是让程序崩溃,模型看到报错后,往往会自己重试一次。

而这一节最重要的,是边界:模型只能看到 schema,真正的 Python 函数在你的进程里执行。模型提出“请读 README.md”,不等于它获得了主机上任意文件的读取权。这个权限由 workspace_path() 决定:它把相对路径拼进 WORKSPACE 后用 resolve() 算出真实路径,再用 is_relative_to() 确认没有靠 ../ 逃出去。模型能「请求」什么,和你的程序「允许」什么,是两条独立的线。

三、把模型和工具接成循环

现在写 agent.py。它的工作是重复执行同一个协议:请求模型,检查是否有 tool_calls,执行每个工具,把结果以 role=tool 写入历史,再请求模型。

协议范围。 本文使用的是 OpenAI-compatible Chat Completions 的消息形状:模型调用在 assistant.tool_calls 中,结果以单独的 role="tool" 消息回传。它不是所有模型 API 的通用 JSON。比如 Anthropic 原生 Messages API 把 tool_usetool_result 放在内容块中;循环职责相同,但消息适配层必须按提供商协议实现。

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
27
28
29
30
# agent.py
from llm import call_llm
from tools import execute_tool, tool_schemas

MAX_TURNS = 10


async def run_agent(messages, user_input):
messages.append({"role": "user", "content": user_input})

for _ in range(MAX_TURNS):
message = await call_llm(messages, tools=tool_schemas())
messages.append(message.model_dump(exclude_none=True))

tool_calls = message.tool_calls or []
if not tool_calls:
return message.content or "模型没有返回文本"

for tool_call in tool_calls:
name = tool_call.function.name
print(f" 调用工具: {name}")
result = await execute_tool(name, tool_call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": name,
"content": result,
})

return "本轮工具调用已达到 10 次上限,请缩小任务后再试。"

这段循环里藏着工具调用协议的三条硬规则,初学者改代码时最容易在这里踩坑:

  • tool_choice="auto"(在 llm.py 里)是把「要不要用工具」的决定权交给模型:auto 让它自行判断,none 强制它别用工具,required 强制它这轮必须用。本文用 auto,因为我们要的正是「模型自己决定何时读文件、何时收尾」。
  • role=tool 的消息必须带 tool_call_id,而且要和 assistant 那条里的 tool_calls[i].id 一一对上。模型是靠这个 id 把「哪条结果对应哪次调用」认回去的。漏了 id、或对不上,API 会直接报错。
  • 一条 assistant 消息里有几个 tool_calls,你就得追加几条 tool 消息,一个都不能少,然后才能再次请求模型。这也是为什么循环里用 for tool_call in tool_calls 把每一个都处理完、各自 append 一条结果,再进入下一轮。如果模型一次要求读三个文件,你只回了两条结果,下次请求就会失败。

MAX_TURNS 很小,却是一个不能省略的工程细节。模型可能因为工具报错、提示词冲突或任务不清楚而重复调用。没有上限,你无法控制时间和费用,也无法判断它是否卡住。

最后用 main.py 把入口接起来:

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
27
28
# main.py
import asyncio
from agent import run_agent
from llm import check_config
from tools import WORKSPACE


async def main():
check_config()
WORKSPACE.mkdir(parents=True, exist_ok=True)
messages = [{
"role": "system",
"content": (
"你是编程助手。只能使用提供的工具处理 workspace 内的文件。"
"修改前先阅读相关文件;测试失败时如实说明结果。"
),
}]
print(f"Agent 已启动,工作目录: {WORKSPACE},输入 quit 退出。\n")

while True:
user = input("你: ").strip()
if user in {"quit", "exit", "q"}:
break
reply = await run_agent(messages, user)
print(f"助手: {reply}\n")


asyncio.run(main())

运行 python main.py 后,可以先问:

1
列出当前工作目录的文件,然后读取 README.md,用一句话说明项目用途。

终端里大致会看到这样一段回显:

1
2
3
4
你: 列出当前工作目录的文件,然后读取 README.md,用一句话说明项目用途。
调用工具: list_files
调用工具: read_file
助手: 这个项目是一个演示用的 Demo project,目前只有一个 README。

如果模型先调用 list_files,再调用 read_file,最后生成总结,说明这个 Agent Loop 已经跑通。模型决定工具序列,你的程序只负责在既定边界内执行并返回观察结果。

把这一轮的 messages 摊开看

上面那三行回显背后,messages 列表到底装了什么?这是整篇文章最该看懂的地方。下面把这一轮结束后的历史逐条列出来(省略了少量字段,突出结构):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[
# 0. 系统提示,一开始就放好,之后每次请求都带着
{"role": "system", "content": "你是编程助手。只能使用提供的工具……"},

# 1. 用户这轮的输入
{"role": "user", "content": "列出当前工作目录的文件,然后读取 README.md,用一句话说明项目用途。"},

# 2. 模型第一次回复:它没说话(content 为空),而是要求调用两个工具
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_a1", "type": "function",
"function": {"name": "list_files", "arguments": "{\"path\": \".\"}"}},
{"id": "call_b2", "type": "function",
"function": {"name": "read_file", "arguments": "{\"path\": \"README.md\"}"}}
]},

# 3 & 4. 你的程序执行工具后,为每个 tool_call 各回一条结果,
# 靠 tool_call_id 和上面的 id 对上号
{"role": "tool", "tool_call_id": "call_a1", "name": "list_files", "content": "README.md"},
{"role": "tool", "tool_call_id": "call_b2", "name": "read_file", "content": "# Demo project\n"},

# 5. 带着工具结果再问一次模型,这次它不再要工具,直接给出最终文本
{"role": "assistant", "content": "这个项目是一个演示用的 Demo project,目前只有一个 README。"}
]

把这几条对着第三节的流程图看,整个循环就具体了:

  1. 第 1 条 user 进来,程序带着全部历史 + 工具 schema 请求模型 → 得到第 2 条。
  2. 第 2 条里有 tool_calls,于是循环没有返回,而是执行两个工具,追加第 3、4 条 tool 结果。
  3. 带着更长的历史再请求一次 → 这次返回的第 5 条没有 tool_callsif not tool_calls 成立,循环结束,把 content 返回给用户。

几个值得停下来看的点:

  • 第 2 条的 contentNone,模型「决定调工具」的那一轮通常不说话。这正是前面 exclude_none=True 要处理的字段之一。
  • arguments 是字符串(注意里面的 \"),不是字典,所以 execute_tool 要先 json.loads
  • 一次 assistant 消息里并排了两个工具调用,就对应下面两条 tool 结果,id 严格配对。少一条,第 5 步的请求就会失败。
  • 到第 5 条为止,模型全程没有「记住」任何东西,它每次都是把这一整个列表重新读一遍。所谓「Agent 的记忆」,物理上就是这个不断变长的列表。

补一层流式实现:事件不是历史

到目前为止,call_llm() 直接交回完整回复,所以代码里看不见流式输出。接入流式模型时,最容易犯的错误是把每个 text_delta 或“参数片段”直接 append 到 messages。这样一条 assistant 回复会被拆成几十条伪消息,恢复会话、统计 token、重试工具调用都会失去边界。

《动手学 Pi》的模型协议和 Agent Loop 采用了更稳的两层模型:事件只用于过程展示,完整消息才进入 transcript。 一次典型工具调用的关系是:

1
2
3
4
5
模型事件:start → text_delta / toolcall_delta → toolcall_end → done

历史消息: 一条完整 assistant(含完整 tool_call)

一条对应 tool result

这不是某家 API 的字段规定,而是一条运行时边界:UI 可以逐字显示 delta,日志也可以记录它;但历史只在流结束、已得到完整 assistant 后追加一次。工具结果继续按本文的 tool_call_id 配对,下一次模型请求读到的才是一段可恢复、可复现的完整往返。

如果以后把本篇改成流式版本,先写三个测试比先接 SDK 更重要:输入 messages 不被运行过程改坏;一轮流只落一条 assistant 历史;工具结果写回后,第二次请求确实带上这条完整 assistant 与结果。这样不会因为“终端看起来在流式输出”就误以为会话状态是正确的。

跑不通时,先查这几处

初学者八成的时间不是花在写代码,而是花在「为什么它不动」。下面是这套最小实现最常见的几种卡点,按现象归类:

报错「不支持 function calling」或启动就退出。这是 check_config() 拦下来的,说明 MODEL 指向的模型不带工具调用能力,或者环境变量没生效。先确认 echo $OPENAI_API_KEY 有值、echo $MODEL 是你要的模型;换过终端窗口后 export 会失效,要重新设。

鉴权失败、401、AuthenticationError。key 没设、设错、或复制时带了空格引号。注意 key 是跟着这个终端会话的,新开一个 tab 要重新 export

模型「不肯」调用工具,直接用文字瞎编答案。这几乎总是 description 写得太含糊,或系统提示没说清「只能通过工具访问文件」。回去把工具的 description 写具体、把系统提示里的边界写死(本文 main.py 的系统提示就是这个作用)。这也是前面强调「description 是给模型看的、不是给人看的」的原因。

工具参数不是合法 JSON。模型偶尔会吐出坏 JSON。本文已经用 try/except 把它兜成一条工具结果返回给模型,通常模型看到后会自己重试。如果反复出现,多半是参数 schema 太复杂,简化 parameters 往往就好了。

role=tool 相关的 API 报错(tool_call_id 对不上 / 缺少 tool 消息)。十有八九是你改动循环时,漏了「每个 tool_call 都要回一条对应结果」这条规则。对照上一节摊开的 messages 检查 id 是否一一配对。

卡住不停地调用工具,直到提示达到上限。这正是 MAX_TURNS 存在的意义,它兜住了你的时间和账单。真卡住时,把每轮的 messages 打印出来看模型在重复什么,通常是某个工具一直返回同样的错误、而提示又没告诉它「此路不通就换个思路」。

调试这类程序有个通用心法:当你不知道发生了什么,就把 messages 整个打印出来。Agent 的所有状态都在这个列表里,看它就能还原模型「看到了什么、于是决定做什么」。

动手改改看

看懂不等于会写。下面三个改动难度递增,建议至少动手前两个:

  1. 加一个 delete_file 工具。照着 write_file 的样子写 schema 和函数,记得路径同样要过 workspace_path()。跑通后想一想:模型现在能删文件了,系统提示要不要补一句约束?
  2. run_tests 加一个可选的 test_path 参数,让模型能只跑某个测试文件而不是全量。写完顺带思考:这个参数要不要也过一遍路径校验?如果模型传进来的是 ../../etc,会发生什么?
  3. print(f" 调用工具: {name}") 换成打印完整的工具参数和返回结果。这其实就是一个最小的「可观测性」改造,真实 Agent 框架里,日志和 trace 是排错的命脉。

四、这个示例解决了什么,还没有解决什么

它已经具备了一个工具型 Agent 的基本闭环:

  • messages 保存会话状态。
  • tool schema 告诉模型有哪些可用能力。
  • role=tool 把真实执行结果交回模型。
  • 循环让模型根据新的观察继续工作。
  • 工作目录、固定测试命令、超时、输出截断和最大回合数限制了最常见的失控路径。

但不要把它当成安全沙箱。workspace_path() 只能约束本文写出的文件工具;pytest 运行的是 Python 代码,恶意测试仍可能访问网络、环境变量或其他主机资源。真实项目还需要容器或虚拟机隔离、权限审批、网络策略、日志和人工复核。

这也是为什么成熟 Agent 框架会显得更重。它们不仅要解决“模型怎样调用工具”,还要解决“谁能批准动作”“执行发生在哪里”“失败后怎样恢复”“如何检查结果”。最小实现的价值在于先把第一件事看明白,再逐层补上工程约束。

下一篇做什么

下一篇先不扩大能力,而是把这个循环工程化:保证 assistanttool 消息严格配对,分开保存应用历史与 API 请求,并用预算和结构化状态报告“完成”还是“未完成”。

安全编辑、会话持久化与上下文压缩会在这之后依次加入;Skills 则放在有了稳定的会话和权限边界之后。这样“能力很多”和“常驻上下文很大”才不会被混成同一个问题。


参考资料