Agent 工程体系 · 第 52/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

编程 Agent 工程:仓库上下文、搜索、补丁、命令、验证和提交

编程 Agent 不是“能生成代码的聊天机器人”,而是一个能够在真实仓库中观察状态、选择工具、修改文件、运行命令、解释失败并继续行动的软件系统。它的工作对象不是一段孤立的代码,而是一个带有目录结构、依赖关系、版本历史、测试约束、构建环境和权限边界的仓库状态

OpenAI 对 Agent 的概括包括规划、调用工具、协作以及保留足够状态以完成多步任务;其运行时本质上是一个循环:调用模型、检查输出、执行工具调用、必要时切换 Agent,直到产生最终结果。(developers.openai.com) Anthropic 则区分了预定义的 workflow 与由模型动态决定下一步的 agent:前者强调可预测性,后者强调灵活性和模型驱动的决策。(anthropic.com)

这一区分对 Coding Agent 尤其重要:

  • 如果任务是“执行固定的格式化、测试、构建流水线”,应该优先使用确定性的 workflow。
  • 如果任务是“定位一个尚不明确的缺陷,并决定需要阅读哪些文件、运行哪些测试”,才需要 Agent 的动态决策能力。
  • 实际系统通常是二者的组合:由 Agent 负责探索和修改,由工程代码负责权限、状态、命令策略和最终门禁。

本文将“编程 Agent 工程”具体化为一条可审计的数据流:

flowchart TD
    U[用户任务] --> I[任务解析与约束提取]
    I --> C[仓库上下文收集]
    C --> S[搜索与证据定位]
    S --> P[生成补丁]
    P --> A[补丁预览与策略检查]
    A -->|通过| W[应用到工作树]
    A -->|拒绝| E[要求模型修正]
    W --> X[执行命令]
    X --> V[验证与失败诊断]
    V -->|失败| S
    V -->|通过| R[变更审查]
    R --> G{是否允许提交}
    G -->|否| O[输出补丁与验证报告]
    G -->|是| T[创建 Git 提交]
    T --> F[提交后确认与交付]

关键点不在于“模型写出了什么”,而在于每一步都产生了可检查的中间状态:模型看到了哪些文件,搜索依据是什么,补丁改了什么,命令返回了什么,验证覆盖了哪些性质,最后提交的对象是否正是验证过的工作树。


一、先定义对象:仓库、工作树、上下文和任务状态

1. 仓库不是目录,而是带历史的状态空间

在本文中,仓库是:

R=(F,H,D,E,P)R = (F, H, D, E, P)

其中:

  • FF:当前文件集合及其内容;
  • HH:版本历史,例如 Git 提交、分支和标签;
  • DD:依赖关系,包括模块、包、配置和生成文件之间的关系;
  • EE:执行环境,例如操作系统、语言版本、依赖缓存和服务;
  • PP:权限与策略,例如哪些文件可以修改、哪些命令需要审批。

Agent 实际操作的通常不是抽象仓库 RR,而是一个工作树

W=(F,B,M)W = (F', B, M)

其中:

  • FF' 是当前磁盘上的文件;
  • BB 是当前分支或提交基线;
  • MM 是未提交变更,包括用户已有修改和 Agent 新增修改。

必须区分 FFFF'。用户可能在 Agent 启动前已经修改了文件,或者另一个进程在运行期间改变了工作树。如果 Agent 直接执行 git add . && git commit,就可能把不属于本次任务的修改一起提交。

因此,一个合格的 Coding Agent 在开始前至少要记录:

pwd
git rev-parse --show-toplevel
git branch --show-current
git rev-parse HEAD
git status --short
git diff --stat

这些命令分别回答:

  1. 当前进程在哪个目录;
  2. 仓库根目录在哪里;
  3. 当前分支是什么;
  4. 任务开始时的基线提交是什么;
  5. 工作树是否干净;
  6. 当前已有变更的规模。

若启动时工作树不是干净的,系统应将任务划分为两个集合:

M0=任务开始前的修改M_0 = \text{任务开始前的修改}

M1=Agent 执行后观察到的修改M_1 = \text{Agent 执行后观察到的修改}

理论上,本次任务产生的变更应是:

Δ=M1M0\Delta = M_1 - M_0

但普通 Git 的差异并不总能可靠地区分两个集合,尤其当 Agent 修改了一个用户已经修改过的文件。因此,生产实现通常选择以下一种策略:

  • 在独立 Git worktree 中执行任务;
  • 为每个任务创建独立分支;
  • 对用户已有修改直接拒绝自动修改;
  • 为每个文件保存启动前快照并执行三方合并;
  • 禁止自动提交,交由人工从差异中选择。

其中,独立 worktree 的价值不是“目录更干净”,而是把任务隔离转换为文件系统和 Git 引用层面的事实,而不是依赖模型记忆。

2. 仓库上下文不是把整个仓库塞进提示词

仓库上下文是 Agent 为完成当前任务而构造的、带来源和边界的证据集合:

C=(Q,T,S,K,X)C = (Q, T, S, K, X)

其中:

  • QQ:用户任务及显式约束;
  • TT:目标文件和符号;
  • SS:搜索得到的相关代码片段;
  • KK:项目规则、构建方式和测试约定;
  • XX:命令结果、测试结果和历史证据。

“把整个仓库读入上下文”通常不是更可靠的方案,因为:

  1. 大量无关内容会稀释关键约束;
  2. 同名函数、旧实现和生成文件会增加误判;
  3. 模型无法自然地区分权威源文件与派生文件;
  4. 上下文越大,越难判断某条信息是否仍然有效。

正确的上下文收集过程是逐步扩大:

任务描述
  → 仓库入口与规则文件
  → 目标模块
  → 直接调用者与被调用者
  → 相关测试
  → 构建、类型和运行环境
  → Git 差异与历史

上下文还应保留来源。例如不要只传给模型:

函数 validate_user 负责校验用户。

而应传递类似结构:

{
  "path": "src/user/validation.py",
  "lines": "42-78",
  "kind": "source",
  "content": "...",
  "reason": "由测试 test_invalid_email 直接引用"
}

这样做的原因是:模型的判断对象不只是代码内容,还包括“这段内容为什么被选中”。来源信息能够降低把测试注释、生成文件或旧文档误认为规范的风险。


二、搜索:不是查找字符串,而是构造证据链

1. 搜索的工程定义

搜索是从仓库状态中选择与任务相关证据的过程。设仓库中所有可检索对象为 OO,任务为 qq,搜索结果为:

S(q)={oOrelevance(o,q)τ}S(q) = \{o \in O \mid \text{relevance}(o, q) \geq \tau\}

其中 τ\tau 是相关性阈值。

但相关性并不只是文本相似度。一个对象可能在文本上高度匹配,却在语义上不相关。例如:

  • 注释中出现了函数名,但代码路径不会执行;
  • dist/ 中有目标字符串,但它是构建产物;
  • 测试夹具中出现异常值,却不是生产规则;
  • 旧迁移脚本中使用了同名字段,但当前版本已不再使用。

因此,Coding Agent 的搜索一般分为四层。

2. 第一层:结构搜索

结构搜索回答“代码在哪里”:

find . -maxdepth 2 -type f | sort
find src tests -type f | sort

随后排除明显的非源文件:

find . \
  -path './.git' -prune -o \
  -path './node_modules' -prune -o \
  -path './dist' -prune -o \
  -type f -print

结构搜索的结果不是最终上下文,而是建立候选空间。它能够告诉 Agent 项目是单体仓库、monorepo、前后端分离,还是由代码生成器维护的多层结构。

3. 第二层:符号和文本搜索

假设任务是“修复订单金额校验”,可以先搜索定义:

rg -n --glob '!dist/**' --glob '!vendor/**' \
  'def validate_amount|function validateAmount|validate_amount|validateAmount' .

然后搜索调用者:

rg -n --glob '!dist/**' \
  'validate_amount\(|validateAmount\(' src tests

最后搜索领域字段:

rg -n --glob '!dist/**' \
  'amount|currency|decimal|round|precision' src tests

三次搜索产生不同类型的证据:

  • 定义:规则在哪里实现;
  • 调用者:规则在什么条件下生效;
  • 领域字段:数值从哪里来、如何转换、如何展示。

只搜索定义而不搜索调用者,是常见误解。一个函数的正确修改范围取决于调用约定。例如把函数从“返回布尔值”改成“抛出异常”,定义本身可能看起来合理,但所有调用者都可能因此改变控制流。

4. 第三层:配置和规则搜索

编程 Agent 还必须搜索项目约束:

find .. -name 'AGENTS.md' -o -name 'CLAUDE.md' -o -name 'CONTRIBUTING.md'
find . -maxdepth 2 \
  \( -name 'pyproject.toml' -o -name 'package.json' \
     -o -name 'Makefile' -o -name 'Taskfile.yml' \
     -o -name 'Dockerfile' \) -print

这些文件可能定义:

  • 测试入口;
  • 格式化和静态检查命令;
  • 禁止修改的生成目录;
  • 依赖安装方式;
  • 提交信息格式;
  • 特定目录的额外规则。

规则具有作用域。仓库根目录中的规则通常影响整个仓库,而子目录中的规则只适用于该目录及其后代。Agent 不应把一条局部规则错误地扩展到整个仓库,也不应忽略更具体的局部规则。

这正是 Agent Skills 中“指令作用域”的工程意义:技能不只是提示词,而是对触发条件、可用资源、工具和版本的治理。一个“数据库迁移 Skill”若能在所有任务中自动触发,反而会把不相关任务引导到高风险工具路径。

5. 第四层:历史搜索

当当前代码无法解释行为时,再搜索 Git 历史:

git log --oneline -- src/orders
git blame -L 42,78 -- src/orders/validation.py
git log -S 'validate_amount' --all --oneline

历史证据可以回答:

  • 某个判断为什么存在;
  • 某个测试是否曾经防止过回归;
  • 当前行为是设计约定还是临时修复;
  • 一个看似冗余的分支是否服务于兼容性。

但历史不是自动的规范。旧提交可能对应不同依赖、不同业务规则或已废弃接口。因此历史搜索的输出应被标记为“背景证据”,不能直接覆盖当前测试、当前文档和当前接口契约。


三、补丁:让模型提出变更,让系统决定能否应用

1. 什么是补丁

补丁是对工作树的受限变换:

Wi+1=apply(Pi,Wi)W_{i+1} = \operatorname{apply}(P_i, W_i)

其中 PiP_i 描述新增、修改或删除哪些文件以及如何变化。

补丁与“让模型输出完整文件”有本质区别:

  • 完整文件输出容易覆盖模型没有读到的内容;
  • 补丁可以被逐行审查;
  • 补丁应用失败时能够精确反馈冲突位置;
  • 补丁天然适合与 Git diff、代码审查和回滚结合。

OpenAI 的 Apply Patch 工具将文件操作划分为创建、更新和删除,并要求宿主环境应用补丁、记录成功或失败,再把结果反馈给模型继续处理。(developers.openai.com) 这体现了一个重要边界:模型提出补丁,执行环境负责落盘

2. 补丁应用不是字符串替换

一个可靠的补丁应用器至少应检查:

  1. 路径是否位于仓库根目录内;
  2. 是否尝试访问符号链接指向的仓库外路径;
  3. 目标文件是否仍与模型读取时一致;
  4. 上下文行是否匹配;
  5. 是否触碰禁止目录;
  6. 删除或重命名是否需要审批;
  7. 文件编码和换行符是否可接受。

路径检查可以抽象为:

resolve(p)allowed_roots\operatorname{resolve}(p) \in \operatorname{allowed\_roots}

不能只检查字符串前缀。例如:

/home/agent/repo/file
/home/agent/repo-other/file

简单的 startswith("/home/agent/repo") 会错误放行第二条路径。正确方式是对规范化后的路径使用路径组件比较。

补丁还应绑定读取版本。设模型读取文件时的哈希为 h0h_0,应用前工作树文件哈希为 h1h_1

h0h1拒绝自动应用或重新搜索h_0 \neq h_1 \Rightarrow \text{拒绝自动应用或重新搜索}

否则模型可能基于旧内容覆盖了用户刚刚写入的修复。

3. 一个最小补丁示例

假设原文件为:

def normalize_name(name: str) -> str:
    return name.strip().lower()

任务要求保留大小写,只删除两端空白。补丁应明确表达意图:

diff --git a/src/name.py b/src/name.py
index 9d2a1ab..52f4e81 100644
--- a/src/name.py
+++ b/src/name.py
@@ -1,2 +1,2 @@
 def normalize_name(name: str) -> str:
-    return name.strip().lower()
+    return name.strip()

应用后必须立即检查:

git diff --check
git diff -- src/name.py

git diff --check 只能检查部分格式问题,不能证明语义正确;git diff 只能证明文件发生了预期变化,不能证明所有调用者都兼容。因此补丁应用完成只是“工作树发生了受控变化”,不是任务完成。


四、命令:让 Agent 观察真实环境,而不是猜测结果

1. 命令工具的定义

命令是 Agent 请求执行环境完成一个外部操作的工具调用,例如搜索文件、运行测试、构建项目或检查 Git 状态。

命令执行结果至少应包含:

Y=(c,o,e,r,t,d)Y = (c, o, e, r, t, d)

其中:

  • cc:实际执行的命令;
  • oo:标准输出;
  • ee:标准错误;
  • rr:退出码;
  • tt:是否超时;
  • dd:执行时的工作目录、环境和资源限制。

只返回一段合并后的文本是不够的。模型必须知道“命令失败”与“命令成功但输出包含警告”的区别。

OpenAI 的 Shell 工具支持托管容器和本地运行时,并明确建议命令执行非交互、保留非零退出结果,在超时时返回部分输出。(developers.openai.com)

2. 命令必须有生命周期

一次命令调用的生命周期可以表示为:

请求
 → 参数校验
 → 策略匹配
 → 审批(必要时)
 → 创建进程组
 → 设置工作目录和环境
 → 捕获 stdout/stderr
 → 处理超时或信号
 → 回收子进程
 → 返回结构化结果

以下是一个框架无关的 Python 最小实现:

from dataclasses import dataclass
import os
import signal
import subprocess
from pathlib import Path

@dataclass
class CommandResult:
    command: str
    cwd: str
    stdout: str
    stderr: str
    exit_code: int | None
    timed_out: bool

def run_command(command: str, cwd: str, timeout: int = 60) -> CommandResult:
    root = Path(cwd).resolve()

    process = subprocess.Popen(
        ["sh", "-c", command],
        cwd=root,
        stdin=subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        start_new_session=True,
        env={
            "PATH": os.environ["PATH"],
            "HOME": str(root / ".agent-home"),
            "CI": "1",
        },
    )

    try:
        stdout, stderr = process.communicate(timeout=timeout)
        return CommandResult(
            command=command,
            cwd=str(root),
            stdout=stdout,
            stderr=stderr,
            exit_code=process.returncode,
            timed_out=False,
        )
    except subprocess.TimeoutExpired as exc:
        os.killpg(process.pid, signal.SIGTERM)
        stdout, stderr = process.communicate()
        return CommandResult(
            command=command,
            cwd=str(root),
            stdout=(exc.stdout or "") + stdout,
            stderr=(exc.stderr or "") + stderr,
            exit_code=None,
            timed_out=True,
        )

这个实现只是教学用最小版本,生产环境仍需处理:

  • 命令注入;
  • 资源限制;
  • 进程逃逸;
  • 文件系统访问;
  • 网络访问;
  • 输出大小;
  • 子进程回收;
  • 密钥泄露;
  • 平台差异。

尤其不能把模型提供的字符串直接拼接到宿主机 Shell 中。命令工具应将“模型意图”和“执行格式”分离。例如为测试提供:

{
  "operation": "run_tests",
  "target": "tests/unit/test_user.py"
}

由宿主程序映射为固定命令:

python -m pytest tests/unit/test_user.py

相比允许模型任意生成 sh -c,这种方式牺牲了一部分灵活性,却显著降低了高风险操作面。

3. 命令结果如何反馈

错误结果不能只说“失败了”。应返回足够诊断的信息:

{
  "command": "python -m pytest tests/unit/test_user.py",
  "exit_code": 1,
  "timed_out": false,
  "stdout_tail": "....F",
  "stderr_tail": "AssertionError: expected 3, got 2",
  "cwd": "/workspace/repo",
  "changed_files": ["src/user.py"],
  "retryable": true
}

其中 retryable 不应由模型猜测,而应由命令分类器或策略层给出:

  • 编译错误:通常可修复并重试;
  • 测试断言失败:可能需要修改代码;
  • 依赖下载失败:可能是环境或网络问题;
  • 权限拒绝:通常不应盲目重试;
  • 超时:需要缩小测试范围或调整资源限制;
  • 发现未提交用户修改:应暂停任务。

五、验证:证明代码满足性质,而不是证明命令执行过

1. 验证的层次

验证是根据任务契约和项目约束,判断当前工作树是否满足可接受条件。

设任务要求为 GG,当前工作树为 WW,验证器为 VV

V(W,G){pass,fail,unknown}V(W, G) \in \{\text{pass}, \text{fail}, \text{unknown}\}

pass 不是“没有看到错误”,而是所有要求的检查都通过。fail 表示至少一个性质被违反。unknown 表示检查没有完成,例如超时、依赖缺失或环境不可用。

典型验证层次如下:

  1. 补丁完整性:是否有空白错误、未预期文件、非法路径;
  2. 语法和类型:代码能否解析、编译或通过类型检查;
  3. 单元测试:局部行为是否符合预期;
  4. 集成测试:模块间接口是否兼容;
  5. 构建验证:生产构建是否成功;
  6. 回归验证:修改是否破坏相关旧行为;
  7. 安全验证:是否引入危险依赖、权限绕过或敏感信息;
  8. 交付验证:提交内容是否与验证内容一致。

验证顺序通常从便宜、局部的检查开始,再逐步扩大范围:

git diff --check
python -m compileall src
python -m pytest tests/unit/test_user.py
python -m pytest
python -m build

但顺序不是固定规范。一个前端项目可能先执行类型检查和 lint,一个数据库迁移任务可能先执行 schema 验证。关键是每个命令都应对应一个明确性质。

2. 测试失败的诊断循环

假设 Agent 修改后运行:

$ python -m pytest tests/unit/test_amount.py
1 failed, 8 passed

E assert Decimal('10.00') == Decimal('9.99')

正确的下一步不是立即再改一遍,而是建立失败分类:

  1. 测试本身错误:预期值不再符合当前规范;
  2. 实现错误:计算或舍入规则错误;
  3. 上下文不完整:Agent 没有读取货币精度配置;
  4. 环境错误:依赖版本或区域设置不同;
  5. 非确定性失败:时间、随机数、并发或外部服务造成。

然后收集证据:

sed -n '1,220p' tests/unit/test_amount.py
rg -n 'Decimal|round|precision|currency' src tests
git diff
git log -S '9.99' --all --oneline

如果模型只根据一条断言失败就修改测试,可能把正确实现改成错误行为。验证结果必须反过来驱动搜索,而不是简单驱动下一次生成。

3. 验证不是越多越好

完整测试并不总是最优选择。设一次验证的收益为 qq,成本为 kk,则可以把检查选择近似为:

maxiqixiλikixi\max \sum_i q_i x_i - \lambda \sum_i k_i x_i

其中:

  • xi{0,1}x_i \in \{0,1\} 表示是否执行第 ii 个验证;
  • qiq_i 是该验证发现本类错误的概率或价值;
  • kik_i 是执行成本;
  • λ\lambda 反映延迟和资源约束。

局部修改先运行局部测试通常合理,但当修改跨越公共接口、序列化格式、数据库 schema 或构建配置时,局部测试通过不能作为最终证据。验证范围必须跟随变更影响面扩大。


六、提交:Git 操作是交付边界,不是格式化动作

1. 提交的含义

提交是把经过选择的工作树差异写入 Git 历史:

Hn+1=commit(Hn,Δ,m,a)H_{n+1} = \operatorname{commit}(H_n, \Delta, m, a)

其中:

  • Δ\Delta:被选择提交的差异;
  • mm:提交信息;
  • aa:作者和提交元数据。

提交具有不可忽略的副作用:它改变共享历史、触发 CI、触发部署或通知。因此“修改文件”和“提交代码”应属于不同风险等级。

推荐的状态机是:

EDITING
  → PATCHED
  → VALIDATING
  → VALIDATED
  → REVIEW_REQUIRED
  → COMMITTED

其中只有 VALIDATED 状态允许进入提交前检查,而且提交前必须重新确认工作树没有发生漂移:

git status --short
git diff --name-only
git diff --check
git diff --cached --check
git diff --cached
git rev-parse HEAD

2. 不要使用 git add .

git add . 的问题不是命令本身错误,而是它的选择范围通常大于任务范围。更安全的方式是先获得允许提交的文件集合:

git add -- src/user.py tests/unit/test_user.py
git diff --cached --name-status
git diff --cached --check

如果系统要求精确到行级别,可以使用交互式暂存:

git add -p

但自动化 Agent 不应假设交互式命令一定可用。命令工具应是非交互的;OpenAI 的 Shell 文档也明确要求不要依赖交互式命令。(developers.openai.com)

3. 提交前必须验证“被提交的对象”

一个典型错误是:

  1. 工作树验证通过;
  2. Agent 又修改了一个文件;
  3. 只暂存了部分文件;
  4. 提交内容与验证内容不一致。

因此验证对象应明确区分:

  • W:完整工作树;
  • Δ:本次任务差异;
  • I:暂存区;
  • C:最终提交。

最终应满足:

files(I)=allowed_files\operatorname{files}(I) = \operatorname{allowed\_files}

并且:

diff(I)=diff(validated state)\operatorname{diff}(I) = \operatorname{diff}(\text{validated state})

如果不能证明这两个条件,就不应自动提交。

提交命令可以是:

git commit -m "fix: preserve input name casing"

提交后还要确认:

git show --stat --oneline HEAD
git status --short
git log -1 --format='%H%n%s'

预期结果包括:

  • HEAD 是刚创建的提交;
  • 提交文件只包含允许范围;
  • 工作树状态符合策略;
  • 提交信息能够说明行为变化,而不是只写“update code”。

4. 自动提交的边界

以下任务通常可以允许自动提交:

  • 运行在隔离分支或独立 worktree;
  • 变更范围有限;
  • 所有验证通过;
  • 提交不触发生产发布;
  • 提交信息由固定规则生成;
  • 没有用户已有修改;
  • 没有删除、迁移、权限或依赖升级等高风险操作。

以下情况应暂停并要求人工确认:

  • 修改认证、授权、支付、数据库迁移;
  • 删除文件或大范围重命名;
  • 更改 CI、部署、容器或密钥配置;
  • 运行需要网络、写外部系统或发布的命令;
  • 测试失败但模型声称“可以忽略”;
  • 工作树在执行期间发生外部变化;
  • Agent 无法区分用户修改和自身修改。

OpenAI 的 Agent SDK 将 guardrail 定义为自动检查,将 human review 定义为在敏感动作前暂停并由人或策略批准;二者共同决定运行继续、暂停还是停止。(developers.openai.com)


七、沙箱:把命令风险限制在执行边界内

1. 沙箱解决什么问题

代码执行沙箱是为 Agent 提供受隔离约束的进程、文件、网络和资源环境。它解决的不是“模型会不会犯错”,而是“模型犯错时,错误影响能否被限制”。

OpenAI 的 Sandbox Agent 文档将沙箱会话描述为拥有文件、命令、端口和供应商隔离状态的执行环境,并区分了 Agent 定义、manifest、sandbox client、session、运行配置和保存状态。(developers.openai.com)

至少应分别设计以下边界:

  • 进程:是否允许创建子进程,如何限制进程组和信号;
  • 容器:是否使用容器、虚拟机或宿主机本地执行;
  • 文件:可读写目录、挂载点、符号链接和敏感文件;
  • 网络:是否允许出站访问,允许哪些域名和端口;
  • 资源:CPU、内存、磁盘、进程数、输出大小和执行时间;
  • 销毁:任务完成、失败或超时后如何回收会话和临时文件。

2. 沙箱并不等于安全

沙箱可能仍然存在:

  • 容器内权限过高;
  • 宿主目录错误挂载;
  • Docker socket 暴露;
  • 网络访问导致数据外传;
  • 依赖安装执行恶意脚本;
  • 日志中泄露环境变量;
  • 临时目录和缓存未销毁;
  • 多个任务复用同一工作空间造成交叉污染。

因此安全模型应是:

安全性=隔离+最小权限+策略+审计+销毁\text{安全性} = \text{隔离} + \text{最小权限} + \text{策略} + \text{审计} + \text{销毁}

缺少任意一项都可能使整体边界失效。

3. 持久状态与会话状态必须分离

一个长任务可能需要暂停、人工审核后继续,或在多次运行之间保留工作成果。这时应区分:

  • 对话状态:模型消息和工具调用历史;
  • 运行状态:当前 Agent、审批状态、工具状态;
  • 沙箱会话状态:正在运行的执行环境;
  • 工作区快照:某一时刻的文件内容;
  • 记忆文件:供后续任务读取的经验或产物。

OpenAI 的沙箱文档明确区分了 RunState、序列化 session state 和 snapshot,并给出了恢复顺序:优先复用活动会话,其次恢复运行状态或序列化会话,最后才创建新会话。(developers.openai.com)

这里有一个常见误解:保存了对话历史,不代表保存了文件系统。模型知道“上次改过 src/app.py”,并不意味着下一次运行中的沙箱还存在该文件。要恢复工作,必须同时持久化或重新构造工作区。


八、一个完整的 Coding Agent 控制循环

下面给出与具体模型和 SDK 无关的伪代码。它展示的是工程生命周期,而不是某个厂商的固定 API:

def coding_agent(task, repo):
    baseline = snapshot_repo(repo)

    context = inspect_repository(
        repo,
        include_rules=True,
        include_status=True,
        include_manifest=True,
    )

    plan = model.plan(task=task, context=context)

    while True:
        evidence = search_repository(
            repo,
            queries=plan.search_queries,
            exclude_generated=True,
        )

        patch = model.propose_patch(
            task=task,
            context=context,
            evidence=evidence,
            constraints=plan.constraints,
        )

        check_patch_policy(patch, repo)
        apply_patch(repo, patch)

        command_results = []
        for check in choose_checks(task, patch, repo):
            result = run_in_sandbox(
                command=check.command,
                cwd=repo,
                timeout=check.timeout,
            )
            command_results.append(result)

            if result.timed_out or result.exit_code not in (0,):
                diagnosis = model.diagnose(
                    task=task,
                    patch=patch,
                    result=result,
                    diff=current_diff(repo),
                )
                if diagnosis.stop:
                    return report_failure(baseline, repo, diagnosis)

                plan = diagnosis.next_plan
                break
        else:
            if human_approval_required(task, patch):
                return request_review(baseline, repo, command_results)

            if not commit_policy_allows(task, patch, command_results):
                return report_success_without_commit(
                    baseline, repo, command_results
                )

            commit_files = select_allowed_files(baseline, repo)
            verify_commit_boundary(commit_files, command_results)
            commit(repo, commit_files, message=make_commit_message(task))
            return report_success(baseline, repo, command_results)

这个循环有四个重要性质。

第一,模型不能跳过仓库检查。即使用户说“只改一行”,Agent 也必须确认目标文件、规则和已有修改。

第二,补丁应用和命令执行是两个阶段。补丁成功不代表程序正确,命令成功也不代表补丁范围正确。

第三,失败会回到搜索和诊断,而不是简单重复相同调用。重复同一动作只会增加成本,不会增加信息。

第四,提交是最后的策略决策,不是工具链的自然终点。某些任务应在验证通过后只输出补丁,由人工完成提交。


九、完整算例:修复一个用户名称规范化问题

假设用户提出:

修复用户名称保存时被强制转为小写的问题,并补充回归测试。

第一步:建立初始状态

git rev-parse HEAD
git status --short
rg -n 'normalize_name|lower\(\)|username|display_name' src tests

假设结果发现:

src/user/name.py:3:def normalize_name(name: str) -> str:
src/user/name.py:4:    return name.strip().lower()

src/user/service.py:18:normalized = normalize_name(request.display_name)
tests/unit/test_name.py:7:def test_normalize_name():

此时还不能马上删除 .lower(),因为需要确定:

  • display_name 是否应该保留大小写;
  • username 是否仍然要求小写;
  • 数据库存储字段是否共用同一个规范化函数;
  • 测试是否把两个概念混在了一起。

第二步:读取定义、调用者和测试

sed -n '1,120p' src/user/name.py
sed -n '1,160p' src/user/service.py
sed -n '1,160p' tests/unit/test_name.py
rg -n 'display_name|username' src tests

假设证据显示:

def normalize_name(name: str) -> str:
    return name.strip().lower()

而测试为:

def test_normalize_name():
    assert normalize_name(" Alice ") == "alice"

这说明函数名过于宽泛,当前测试只证明旧行为,不足以证明业务需求。Agent 应先区分两个概念:

def normalize_display_name(name: str) -> str:
    return name.strip()

def normalize_username(name: str) -> str:
    return name.strip().lower()

如果调用者确实把 display_name 传给了原函数,则补丁应修改调用路径,并保留用户名路径的大小写折叠。

第三步:生成最小补丁

diff --git a/src/user/name.py b/src/user/name.py
@@
-def normalize_name(name: str) -> str:
-    return name.strip().lower()
+def normalize_display_name(name: str) -> str:
+    return name.strip()
+
+def normalize_username(name: str) -> str:
+    return name.strip().lower()

调用者:

diff --git a/src/user/service.py b/src/user/service.py
@@
-normalized = normalize_name(request.display_name)
+normalized = normalize_display_name(request.display_name)

测试:

diff --git a/tests/unit/test_name.py b/tests/unit/test_name.py
@@
-def test_normalize_name():
-    assert normalize_name(" Alice ") == "alice"
+def test_normalize_display_name_preserves_case():
+    assert normalize_display_name(" Alice ") == "Alice"
+
+def test_normalize_username_lowercases():
+    assert normalize_username(" Alice ") == "alice"

第四步:应用并验证

git diff --check
python -m pytest tests/unit/test_name.py
python -m pytest tests/unit/test_user_service.py
python -m pytest

如果全量测试失败,不能直接判断是补丁错误。例如某个测试可能仍然导入旧符号:

ImportError: cannot import name 'normalize_name'

此时 Agent 需要搜索所有引用:

rg -n 'normalize_name' .

如果确认这是公开模块 API,则不能随意删除旧函数;可以保留兼容包装:

def normalize_name(name: str) -> str:
    """Backward-compatible username normalization."""
    return normalize_username(name)

这就是上下文对补丁的约束作用:局部修复并不自动授权破坏公共接口。

第五步:提交前检查

git status --short
git diff --name-only
git diff -- src/user/name.py src/user/service.py tests/unit/test_name.py

确认没有额外文件后:

git add -- \
  src/user/name.py \
  src/user/service.py \
  tests/unit/test_name.py

git diff --cached --check
python -m pytest
git diff --cached --name-status
git commit -m "fix: preserve display name casing"

提交前重新运行全量测试,是为了确认暂存区中的内容和此前验证的工作树一致。若中间又有修改,应取消提交流程并重新验证。


十、典型失败路径与诊断方法

1. 上下文不足导致“看似合理”的错误

表现:Agent 修改了目标函数,但遗漏了生成代码、配置映射或另一个调用者。

诊断

rg -n '目标符号|字段名|接口路径' .
git diff --name-only

原因:搜索只覆盖定义,没有覆盖引用和配置。

恢复:以当前补丁中的每个改动符号为新查询,重新寻找调用者、测试和接口边界。

2. 补丁上下文冲突

表现:补丁无法应用,或者应用后差异异常大。

原因

  • 文件在模型读取后发生变化;
  • 行号依赖过强;
  • 目标文件包含自动生成内容;
  • 换行符或格式化工具重写了文件。

恢复

git diff
git status --short
git hash-object path/to/file

重新读取目标区域,生成更小的补丁,而不是强制覆盖整个文件。

3. 命令成功但结论错误

表现pytest 返回 0,但功能仍然错误。

原因

  • 测试没有覆盖任务场景;
  • 运行了错误的 Python 环境;
  • 测试被跳过;
  • 只运行了旧测试;
  • 测试断言过弱。

诊断

python --version
python -m pytest -ra
python -m pytest --collect-only
git diff --stat

命令退出码只是一个信号,不是完整证明。验证设计必须确认“测试确实执行了相关断言”。

4. 沙箱泄露或污染

表现:不同任务看到彼此文件,或者任务能够读取宿主机密钥。

原因

  • 复用了同一个持久会话;
  • 挂载范围过大;
  • 环境变量全部继承;
  • 任务完成后没有销毁临时目录;
  • 网络出口没有策略控制。

恢复

  1. 停止并销毁当前会话;
  2. 检查挂载、环境和网络日志;
  3. 轮换可能暴露的密钥;
  4. 为每个任务创建全新工作空间;
  5. 仅恢复经过审核的快照,而不是整个会话。

十一、工程边界:哪些应交给模型,哪些必须由系统掌控

适合交给模型的内容:

  • 根据任务决定先搜索哪些符号;
  • 从多个文件中推断调用关系;
  • 解释测试失败的可能原因;
  • 生成小范围补丁;
  • 选择下一步诊断动作;
  • 形成变更说明。

不应只交给模型决定的内容:

  • 是否能访问某个目录;
  • 是否允许执行任意 Shell;
  • 是否允许出站网络;
  • 是否能删除文件;
  • 是否能修改 CI、部署和密钥配置;
  • 是否能提交或推送;
  • 是否可以忽略测试失败;
  • 是否把用户已有修改纳入提交。

这不是“信任模型”与“不信任模型”的二元问题,而是职责分离问题。模型适合处理不确定的语义搜索和计划选择;宿主系统适合处理确定的安全策略、状态转换、资源回收和审计。

从框架角度看,Agent SDK 的运行循环、工具、handoff、审批、状态和沙箱都属于可组合运行时能力;但这些能力并不会自动替代仓库策略。(developers.openai.com)


十二、判断一个编程 Agent 是否可靠

可靠性不能用“生成代码速度”衡量,而应检查以下不变量:

I1: 所有修改都位于允许仓库根目录内I_1:\ \text{所有修改都位于允许仓库根目录内}

I2: 补丁应用前后都能重建准确 diffI_2:\ \text{补丁应用前后都能重建准确 diff}

I3: 每个命令都有退出码、输出、超时和工作目录I_3:\ \text{每个命令都有退出码、输出、超时和工作目录}

I4: 验证结果对应的是最终待提交内容I_4:\ \text{验证结果对应的是最终待提交内容}

I5: 提交文件集合不包含任务外修改I_5:\ \text{提交文件集合不包含任务外修改}

I6: 敏感动作可暂停、拒绝并恢复I_6:\ \text{敏感动作可暂停、拒绝并恢复}

I7: 失败后能返回搜索或诊断,而不是盲目重复I_7:\ \text{失败后能返回搜索或诊断,而不是盲目重复}

如果一个系统只能“让模型编辑文件”,却不能回答“模型依据哪些上下文编辑、补丁是否冲突、命令在哪里执行、测试是否真的覆盖、提交包含哪些文件”,它还不是完整的编程 Agent 工程。

真正可维护的 Coding Agent,应把仓库上下文、搜索、补丁、命令、验证和提交组织成一条具有明确状态和证据的闭环:模型负责在不确定性中提出行动,工具负责产生事实,策略层负责限制副作用,验证器负责判断性质,Git 负责记录最终交付。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。