算数工具算错了无非答案错,文件工具写错了你的代码就没了。本文是「从零手写 Agent」系列第 6 篇:给 Agent 装上读文件、写文件、搜代码、跑 Python 的真实工具,同时把安全边界画好。系列第 5 篇的循环五件套已经能转,但工具栏里还只有计算器与星期几——Agent 空有决策力,手上没有真家伙。本篇还包含三个真实事故的解剖——它们比正确代码更有教学价值。
本文按"三层安全边界 → 工具设计三原则 → 三个真实事故 → 总结"组织。
1、三层安全边界
真实工具与玩具工具的差别在风险等级。工程上用三层边界控制风险,责任全部在执行侧。
算错一个数与删错一个文件不是同一种错误,边界要按工具的风险画像分别布防。四类工具的风险画像与对应边界对比如下:
| 工具 | 出错后果 | 主要边界 |
|---|---|---|
| 读文件 | 大文件撑爆上下文 | 分页与单行截断 |
| 写文件 | 覆盖源码、写坏配置 | 路径白名单加原子写 |
| 搜代码 | 匹配过多刷屏 | 条数上限 |
| 执行代码 | 任意命令与死循环 | 子进程、超时、容器 |
解读:风险从上到下递增,边界也从"省着点用"升级到"关进笼子"。三层边界不是三个并列的开关,而是同一条执行链上的三道闸门——任何一道失守,第 3 节的事故清单里都有对应的案例。
1.1、路径白名单
第一层是路径白名单:所有文件操作先过一道检查,路径解析后必须落在指定工作目录内,越界直接拒单。核心实现不到十行:
from pathlib import Path
WORKSPACE = Path(__file__).resolve().parent.parent
def _safe_path(path: str) -> Path:
p = Path(path)
resolved = (p if p.is_absolute() else WORKSPACE / p).resolve()
if not resolved.is_relative_to(WORKSPACE): # resolve 展开 ../ 穿越
raise PermissionError(f"拒绝:路径越界 {path}")
return resolved
解读:resolve() 会把 ../ 展开成真实路径,穿越企图在这里现形;is_relative_to 完成白名单判定。抛出的异常会被 Agent 循环的容错层转成文字结果回传,模型看到"拒绝:路径越界"会自己调整策略。安全在代码层,不靠模型自觉——这是"模型点菜、厨房有权拒单"的落地。
白名单不只管读。写文件工具除了过同一道检查,还要防"写一半"——进程中途死掉,半截文件就把原内容覆盖了。工程做法是先写临时文件、再原子替换:
import os, tempfile
def safe_write(path: str, content: str) -> str:
target = _safe_path(path) # 复用同一道白名单
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=target.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content) # 先写完整的临时文件
os.replace(tmp, target) # 原子替换:要么旧要么新
finally:
if os.path.exists(tmp):
os.remove(tmp) # 清掉失败残留
return f"已写入 {len(content)} 字符 → {path}"
解读:os.replace 在同一目录内是原子操作,任意时刻磁盘上要么是完整的旧文件、要么是完整的新文件,不存在中间态。临时文件刻意建在目标同目录——跨目录替换在 Windows 上拿不到原子性保证。返回值带写入字符数,延续后面要讲的"返回值自描述"约定。
1.2、软限制与分页
第二层是软限制:读文件按行分页并限单行长度、搜索限条数、执行限超时,防止单次工具调用把上下文撑爆。分页读文件的头部元信息设计如下:
[miniagent\agent.py 共119行 | 本次第1-20行 | 未完,用 offset=20 继续]
1 """
2 L1 · 用裸 HTTP 请求调用大模型 API
...
解读:返回头部自带三件事——文件多大、本次读到哪、下一页参数是什么。模型照抄提示里的数字即可翻页,不需要理解参数语义,这叫工具自描述。
头部承诺能兑现,靠的是分页实现本身。读文件工具的完整实现如下:
MAX_LINES, MAX_LEN = 20, 200 # 单页 20 行;单行最多 200 字符
def read_file(path: str, offset: int = 1, limit: int = MAX_LINES) -> str:
lines = _safe_path(path).read_text(
encoding="utf-8", errors="replace").splitlines()
page = [f"{n}\t{line[:MAX_LEN]}" for n, line in # 行号做前缀
enumerate(lines[offset - 1:offset - 1 + limit], offset)]
last = offset + len(page) - 1
header = f"[{path} 共{len(lines)}行 | 本次第{offset}-{last}行"
if last < len(lines):
header += f" | 未完,用 offset={last + 1} 继续" # 兑现翻页承诺
return header + "]\n" + "\n".join(page)
解读:两个上限各管一件事——MAX_LINES 管一页读多少,MAX_LEN 防一手"单行暗算":压缩成一行的 JSON 或打包产物,一行就可能上万字符,不限长度的话分页形同虚设。每行带行号前缀,模型在最终答案里就能用"agent.py 第 49 行"引用证据,第 4 节的实测会用到这个能力。
1.3、代码执行沙箱
第三层是沙箱声明:教学版用子进程加超时兜底,并诚实在注释里写明这不是真沙箱;生产环境执行模型生成的代码必须上容器。Claude Code 的权限系统就是把"拒单权"做成了产品。
教学版执行工具的骨架如下:
import subprocess, sys
def run_python(code: str) -> str:
"""子进程执行模型生成的 Python。注意:这不是真沙箱,仅教学演示。"""
try:
r = subprocess.run(
[sys.executable, "-c", code], # 独立进程:崩溃不连坐主程序
capture_output=True, text=True, timeout=10, cwd=WORKSPACE,
)
except subprocess.TimeoutExpired:
return "错误:执行超时(10 秒),进程已终止" # 失败要大声
out = r.stderr if r.returncode != 0 else r.stdout
return f"退出码 {r.returncode}\n{out or '(无输出)'}"
解读:独立子进程保证模型代码崩了不连坐主程序,超时管住死循环,cwd 钉在工作目录。注释里"这不是真沙箱"必须写——子进程没有隔离文件系统与网络,路径白名单只拦"走工具"的调用,拦不住模型在代码里自己写的 os.remove。另外 text=True 在中文 Windows 上按 GBK 解码,这里埋着一颗编码地雷,第 3 节事故三就是它的引爆现场。
生产环境执行模型代码的最小清单如下:
- 容器隔离:禁网络、只读根文件系统、CPU 与内存限额
- 降权执行:独立低权限用户与独立临时目录
- 一次性环境:每次执行新起容器,用完即毁
- 审计留痕:执行的代码与输出逐条落盘
这份清单的每一项都在回答同一个问题:如果模型故意使坏,最坏能坏到哪里。进程与超时参数的细节见 Python subprocess 文档,路径处理工具见 pathlib 文档。
2、工具设计三原则
装上真实工具后,工具本身的设计质量成为 Agent 能力的主要瓶颈,三条原则在实测中反复兑现。
原则一:描述是工具的唯一 UI。模型看不见函数体,何时调用、怎么填参全凭 description 与参数描述。搜索工具的描述应当写明支持目录还是单文件、是否区分大小写——描述与行为不一致时,模型按旧认知使用新工具,错得悄无声息。
原则二:返回值要自描述。返回星期几就返回"星期四",不要返回裸整数 3 让模型猜编码约定;分页读文件时在返回头部带上"共多少行、本次读到哪、下一页参数是什么",模型照抄提示里的数字即可翻页,零学习成本。
原则三:失败要大声。区分"真没找到"与"工具失灵"是调用方的权利,静默返回空结果会把失灵伪装成正常答案,误导模型也误导排查的人。
三条原则看着朴素,落地各有样板。前两条展开如下。
2.1、工具说明书实例
原则一的具体落地,是一份把能力边界写死的说明书。搜索工具的完整定义如下:
{
"name": "search_code",
"description": "在工作目录内逐行搜索代码,不区分大小写,只支持文本文件,最多返回 30 条匹配行。",
"input_schema": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "要搜索的子串"},
"path": {"type": "string", "description": "可选,限定单个文件;缺省搜索全目录"}
},
"required": ["pattern"]
}
}
解读:description 把"支持什么、不支持什么、上限多少"全部写死——搜全目录还是单文件、是否区分大小写、最多返回几条,这些正是模型决策时要查的参数表。反例是描述只写"搜索代码"四个字,模型只能靠猜:猜错默认范围、猜错匹配规则,错得悄无声息。说明书里省下的每句话,都会变成模型的一次瞎猜。
2.2、失败要走协议
原则三的"大声"在协议层有标准做法:结果块带 is_error 标记。错误结果的示例如下:
{
"type": "tool_result",
"tool_use_id": "toolu_01ab",
"is_error": true,
"content": "PermissionError: 拒绝:路径越界 ../secrets.py"
}
解读:is_error 把"工具失灵"与"工具正常报告了否定结果"在协议层分开——模型看到错误标记会调整策略,换路径、换工具或向用户澄清,而不是把异常文本当数据继续推理。1.1 节白名单抛出的越界拒绝,正是以这种形式回传给模型的。日志侧同样受益:按工具统计错误率,是发现"描述与行为不一致"这类隐性缺陷最快的途径。字段细节见 Anthropic 工具使用文档。
3、三个真实事故解剖
真实运行暴露的三个缺陷,每一个都对应一条反例。
事故一是空头支票:读文件工具截断长文件时提示"可分段读",但工具根本没有分页参数——提示在撒谎,模型想翻页也无处下手,只能换执行代码的方式绕行。修复是补上 offset 与 limit 参数,头部返回"未完,用 offset=N 继续"。
事故二是漏读:修复分页后引入了新缺陷——字符上限砍断正文,续读提示却按行数跳页,两个口径不一致导致被砍掉的那段行号既不在本页也被下一页跳过。修复是逐行拼装、撞线即停、续读位置用实际停下的那一行。教训是分页逻辑必须做覆盖率测试:模拟翻完全程,断言每个行号恰好出现一次。
这条教训值得写成真正的测试。覆盖率测试的实现如下:
import re
def test_read_file_covers_all_lines(path, total):
seen, offset = [], 1
while True:
result = read_file(path, offset=offset)
body = result.splitlines()[1:] # 跳过头部元信息行
seen += [int(l.split("\t")[0]) for l in body if "\t" in l]
m = re.search(r"offset=(\d+) 继续", result)
if not m:
break # 翻完了
offset = int(m.group(1)) # 照抄提示里的数字
assert seen == list(range(1, total + 1)) # 每行恰好出现一次
解读:测试不调用真模型,只机械照抄续读提示——这正是模型的行为模式。断言"每个行号恰好出现一次"能同时抓住两类缺陷:漏读(行号缺失)与重读(行号重复)。事故二那种"两个口径不一致"的边界缺陷,人工翻两页很难恰好撞上,覆盖率测试一次抓全。
事故三是编码三明治:执行工具给子进程设了 UTF-8 输出,父进程却按系统默认的 GBK 解码,读取线程抛异常后静默丢失输出,工具返回"无输出,退出码 0"——看起来像正常结果。修复是父子两端都钉死 UTF-8:
r = subprocess.run(
[sys.executable, "-c", code],
capture_output=True, timeout=10, cwd=WORKSPACE,
encoding="utf-8", errors="replace", # 父进程按 UTF-8 解码
env={**os.environ, "PYTHONUTF8": "1"}, # 子进程按 UTF-8 输出
)
解读:编码问题的铁律是生产端与消费端必须一致,只修一端等于制造新的错配。判断这类静默失败的心法:工具返回的信息与已知事实对不上时,先怀疑管道,再怀疑代码。
补一句后续:无论执行侧用读取线程还是 text=True,病根都是同一个——两端的编码口径没钉死。修复后这条铁律也写进了 1.3 节骨架的注释里。
4、从能干活到会干活
装好工具与边界后,Agent 交出的答卷相当体面。让它调研"工具执行发生在源码哪个方法",它三轮完成:先搜代码定位、再读文件确认、最后用行号引用作答——一个会读代码的 Agent,就是编码助手的胚胎,全部零件都是本系列前五篇的积累。
拆开这份答卷,每一步都踩在上一步的结果上:第一轮搜索拿到候选文件与行号,第二轮按行号精读命中段落,第三轮回答里的文件名与行号一一对应——工具返回的自描述信息被模型原样利用。三轮总消耗约八千 token,工具结果占了大头(本项目实测口径)——这份账单,正是下一篇要治理的膨胀源头。
对照第 5 篇那个只有计算器的 Agent:同样的任务,它只能凭参数化知识猜一个方法名。工具栏决定能力上限,循环只决定能不能走到上限。
5、总结
给 Agent 装真实工具,功率与风险同步放大:路径白名单锁死活动范围,软限制防撑爆上下文,生产执行代码必须容器沙箱;工具设计上,描述是 UI、返回值要自描述、失败要大声。三个真实事故分别对应三条反例——工具提示必须可兑现、分页要做覆盖率测试、编码两端必须一致。下一篇处理 Agent 的两大绝症:失忆与破产。文件安全与权限设计可参考 Claude Code 文档。

