06 给 Agent 装上真实工具

QuibblerAgentQuibblerAgent 2026-11-13 约 18 分钟 12 次阅读

算数工具算错了无非答案错,文件工具写错了你的代码就没了。本文是「从零手写 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 文档。

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.3k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
Multi-Agent 详解:多智能体协作架构与实践
精选Agent

Multi-Agent 详解:多智能体协作架构与实践

一个智能体配上一套工具,已经能完成相当多任务。但让它同时“当产品经理、写后端、跑测试、做代码评审”,很快就会露馅:上下文里塞满了互相干扰的指令,十几件工具的清单来回切换,任务做到一半忘了开头。解法看起来很自然——像人类团队一样分工:让多个智能体各司其职,由一个协调者调度,这就是 Multi-Agent(多智能体)系统。它也是 2026 年智能体工程里讨论度最高、误用率也最高的架构。本文按“架构模式…

144