Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第24章 从零实现一个可观测 Coding Agent

一个接近生产级的 Agent 原型,不是把模型接到代码仓库上就结束,而是把上下文、工具、权限、验证、trace 和失败复盘都放进同一个闭环。

引言

前面的章节已经讨论了 Prompt、Context、Harness、Tool Runtime、Workflow、RAG、Memory、Evals、Guardrails 和可观测性。第 13 章从成熟产品角度拆解了 Claude Code、Cursor、Codex 这类 AI Coding Agent 的系统设计,第 14 章又从 Pi 出发分析了终端原生 Coding Agent Runtime 的上下文、工具、扩展和 SDK 边界。

本章把这些概念压缩进一个讲解性案例:一个最小但完整的 Coding Agent。本文不附带配套源码、可执行项目或配置模板;其中的模块名、目录名、配置和代码片段都用于说明设计边界,而不是运行说明。

该案例假定 Agent 能读取项目规则和仓库结构,让模型输出结构化 JSON action,通过工具注册表执行读文件、搜索、局部编辑、运行验证和查看 diff,并把每一步保存为 JSONL trace。

本章的目标不是复刻商业级 Coding Agent 的全部能力,也不是给出一个可直接部署的原型,而是讲清一个可继续演进到生产级系统的工程骨架。


23.1 案例目标与边界

本章使用一个最小但完整的 Coding Agent 作为讲解性案例,不对应任何已实现或可运行的配套项目。

它解决一个窄但典型的问题:

给定一个代码仓库和一段自然语言任务,让 Agent 读取上下文、修改文件、运行验证、输出 diff 和 trace。

典型任务是:

给 calculator.py 的 divide 函数补充除零错误处理,并添加 pytest 测试。
要求:b 为 0 时抛出 ValueError;保留原有正常除法行为;运行 pytest 验证。

这个 MVP 的能力范围:

  • 加载项目规则,例如 AGENT.mdAGENTS.mdCLAUDE.md.cursorrules
  • 构建 repo map,让模型知道仓库有哪些可读文件;
  • 要求模型每轮输出一个 JSON action;
  • 执行 list_filesread_filesearch_codereplace_in_filecreate_filerun_shellgit_diff
  • 用 Policy 控制读、写和 shell 权限;
  • 用路径沙箱限制文件访问范围;
  • 保存每一步 thought、tool call、tool result 和 final;
  • 输出本次修改的文件和 git diff
  • 提供 unit tests 验证工具、配置、trace 和 Agent Loop。

它明确不做:

  • 不做多 Agent 协作;
  • 不做远程分支、PR、发布或部署;
  • 不执行任意 shell 命令;
  • 不读取 secret、.env.pem、真实 API key 或仓库外文件;
  • 不保证复杂重构一次成功;
  • 不把模型 final answer 当成完成证据。

边界清楚是生产化的第一步。一个 Agent 如果从第一天就能做任何事,通常也意味着它从第一天就能把问题做大。


23.2 接近生产级 Coding Agent 的最小闭环

Coding Agent 的核心不是“自动写代码”,而是一个带控制面的执行闭环。

flowchart TD
    Task["User Task\n自然语言需求"] --> Context["Context Builder\n规则 / repo map / recent observations"]
    Context --> Model["LLM\n输出 JSON action"]
    Model --> Parser["Action Parser\n解析 / 修复 JSON"]
    Parser --> Policy["Policy Engine\nallow / ask / deny"]
    Policy --> Tools["Tool Runtime\nread / search / edit / shell / diff"]
    Tools --> Trace["Trace Writer\nJSONL step log"]
    Tools --> State["Agent State\nsteps / changed_files / status"]
    State --> Context
    State --> Final["Final Report\nsummary / verification / diff"]

这条链路里,每一层都有明确职责:

职责不应该做什么
Context Builder给模型准备规则、文件地图和最近观察不直接决定工具权限
LLM理解任务、规划下一步、生成 action不直接读写文件
Parser把模型输出变成结构化数据不默默吞掉错误
Policy Engine判断工具调用是否允许不依赖模型自觉
Tool Runtime执行确定性外部能力不扩大模型权限
Trace Writer记录每一步证据不只保存最终答案
Verifier用测试和 diff 验证完成度不相信“看起来完成了”

如果把这些职责混在一个巨大函数里,Demo 可能能跑,但系统很难调试、扩展和审计。


23.3 概念模块边界

为方便讨论,下面用一组假设的模块名称描述这一类 Runtime 的职责。它们不是本仓库的目录树,也不构成可运行项目:

每个文件只负责一件事:

文件职责
models.py定义 Agent 状态、工具调用、工具结果和步骤记录
context.py加载项目规则,构建 repo map
tools.py实现文件、搜索、编辑、shell 和 diff 工具
policy.py决定工具调用是 allow、ask 还是 deny
llm.py隔离模型供应商,提供 complete(prompt) 接口
agent.py组织 Agent Loop、解析 action、执行工具、记录状态
trace_writer.py把每一步写入 JSONL
verifier.py运行验证命令,并检查 diff
run.pyCLI 入口,把配置、模型、Agent 和 trace 串起来
tests/验证配置、工具边界和 Agent Loop

这个拆分方式有一个重要好处:后续要把模型换成 OpenAI、Claude、Gemini 或本地模型时,只需要替换 LLM Adapter;要把 shell 权限变严格时,优先调整 Policy Engine 和 Tool Runtime;要接入 LangSmith 或 OpenTelemetry 时,可以从 Trace Writer 扩展。


23.4 确定性控制面的验证范围

若把这一设计落地为自己的项目,首先应为确定性控制面编写测试;这些测试不需要真实模型,也不需要 API key。验证范围包括:

  • 配置文件是否能正确加载;
  • 路径沙箱是否阻止越界访问;
  • forbidden 文件是否不能读取;
  • shell allowlist 是否生效;
  • fake LLM 是否能驱动 Agent Loop 创建文件;
  • trace 是否能写出 JSONL。

生产级 Agent 的第一条原则是:能不用模型测的部分,都不要依赖模型来测。


23.5 配置模型和运行参数

下面是讲解性配置片段,用来说明应被外置的运行时策略;不要将其视为本书配套文件或复制到生产环境:

[llm]
provider = "deepseek"
base_url = "https://api.deepseek.com"
api_key = "sk-your-deepseek-api-key"
model = "deepseek-v4-flash"
temperature = 0
max_tokens = 4096
timeout = 120
thinking = "disabled"

[agent]
max_steps = 20
auto_edit = false
auto_shell = false

[shell]
allowed_commands = ["python", "python3", "pytest", "ruff", "mypy", "npm", "pnpm", "make", "go"]
timeout = 60

这里有三个控制面:

  1. llm:模型供应商、地址、模型名、温度、超时;
  2. agent:最大步数、是否自动编辑、是否自动执行 shell;
  3. shell:允许执行的命令和超时时间。

示例配置采用安全默认值:允许读文件、搜索和查看 diff,但写文件与 shell 执行默认进入审批路径。若在自己创建的临时沙箱中实验,可在独立的本地配置里显式开启 auto_edit=trueauto_shell=true,并把 allowed_commands 收窄到验证命令。

任何真实配置文件都不应提交 API key。

为什么配置要外置

不要把模型、key、命令白名单和超时时间写死在 agent.py 里。它们属于运行时策略,应该可以按环境切换:

环境推荐策略
临时沙箱在独立本地配置中显式开启 auto_edit=trueauto_shell=true,只允许验证命令
团队共享环境auto_edit=true,高风险 shell 需要审批
CI / eval使用固定模型版本、固定 temperature、固定数据集
生产代码库默认 read-only,按任务逐步开放写权限

模型能力会变化,安全策略也会变化。把策略外置,系统才有演进空间。


23.6 核心数据模型

models.py 定义了四个核心结构。

from dataclasses import dataclass, field
from typing import Any, Literal


@dataclass
class ToolCall:
    name: str
    args: dict[str, Any] = field(default_factory=dict)


@dataclass
class ToolResult:
    ok: bool
    content: str
    error: str = ""


@dataclass
class AgentStep:
    thought: str
    tool_call: ToolCall | None = None
    tool_result: ToolResult | None = None
    final: dict[str, Any] | None = None


@dataclass
class AgentState:
    task: str
    repo_root: str
    messages: list[dict[str, str]] = field(default_factory=list)
    steps: list[AgentStep] = field(default_factory=list)
    changed_files: set[str] = field(default_factory=set)
    status: Literal["running", "done", "failed"] = "running"
    final: dict[str, Any] | None = None

这些结构看起来简单,但它们决定了系统能不能复盘。

ToolCall 表示模型想做什么;ToolResult 表示系统实际做了什么;AgentStep 把模型意图和工具结果绑定在一起;AgentState 保存任务生命周期。

不要只保存最终回答。真正排查失败时,你需要知道:

  • 哪一轮模型开始偏航;
  • 它当时看到了哪些 observation;
  • 它选择了哪个工具;
  • 工具参数是否越界;
  • 工具失败后模型有没有修复;
  • final 是否有验证证据。

这就是为什么 trace 要从第一版就存在。


23.7 Context Builder:让模型先看边界,再看代码

Coding Agent 的上下文不应该是“把整个仓库塞进 prompt”。更稳妥的做法是先给模型三类信息:

  1. 项目规则;
  2. 仓库文件地图;
  3. 最近几轮工具结果。

context.py 里的规则加载很克制:

def load_rules(repo_root: Path) -> str:
    for name in ["AGENT.md", "AGENTS.md", "CLAUDE.md", ".cursorrules"]:
        path = repo_root / name
        if path.exists() and path.is_file():
            return path.read_text(encoding="utf-8")[:4000]
    return ""

仓库地图也只收集文本类文件,并跳过缓存、构建产物和 trace:

IGNORE_DIRS = {
    ".git",
    "__pycache__",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".venv",
    "book",
    "build",
    "dist",
    "node_modules",
    "public",
    "traces",
}

这体现了 Context Engineering 的核心原则:上下文要服务当前任务,不要把噪声包装成“信息充分”。

生产级增强方向

MVP 的 repo map 只是文件列表。生产级 Coding Agent 通常还会加入:

  • 语言级索引,例如函数、类、接口、路由、测试名;
  • 最近修改文件和 git status;
  • issue、PR、设计文档、ADR;
  • 失败测试输出;
  • 依赖图和模块边界;
  • 目录级规则;
  • 与当前任务相关的 Skill。

但第一版不要急着做复杂索引。先证明 read/search/edit/test/trace 的主链路可靠,再扩展上下文来源。


23.8 模型输出协议:每轮只返回一个 JSON 对象

Agent Loop 最怕模型输出自由文本,Runtime 不知道该执行什么。

本章的 MVP 不使用模型原生 tool calling,而是要求模型返回 JSON:

{
  "thought": "我需要先搜索 divide 函数在哪里。",
  "action": {
    "name": "search_code",
    "args": {
      "query": "def divide",
      "pattern": "*.py"
    }
  }
}

任务完成时返回:

{
  "thought": "实现和测试都完成了。",
  "final": {
    "summary": "为 divide 增加了除零检查,并补充了测试。",
    "verification": "pytest 通过",
    "changed_files": ["calculator.py", "test_calculator.py"]
  }
}

agent.py 的系统提示词故意很短:

SYSTEM_PROMPT = """You are a coding agent working inside a repository.
You can only act by returning exactly one JSON object.
Use tools to inspect, edit, verify, and review.
Do not claim success without verification evidence.
Read files before editing them.
Prefer replace_in_file over rewriting whole files.
"""

更复杂的生产系统可以换成原生 tool calling,但不要丢掉这些 Runtime 抽象:

  • 工具注册表;
  • 参数校验;
  • 权限策略;
  • trace;
  • verifier;
  • final report schema。

原生 tool calling 解决的是“模型如何结构化表达工具调用”,不自动解决“工具是否安全”和“任务是否完成”。


23.9 Tool Runtime:工具是能力边界,不是普通函数

tools.py 暴露七个工具:

工具作用风险
list_files查看仓库文件
read_file读取文件片段低到中,取决于 secret 过滤
search_code搜索代码文本
replace_in_file精确替换一个片段
create_file创建新文件
run_shell执行验证命令
git_diff查看工作区 diff

路径沙箱

第一条硬规则:工具只能访问 --repo 指定的 workspace 内部。

class Workspace:
    def __init__(self, root: str | Path):
        self.root = Path(root).resolve()

    def resolve(self, relative_path: str | Path) -> Path:
        path = (self.root / relative_path).resolve()
        if path != self.root and self.root not in path.parents:
            raise ValueError(f"path escapes workspace: {relative_path}")
        rel_parts = path.relative_to(self.root).parts
        if any(part in FORBIDDEN_DIRS for part in rel_parts):
            raise ValueError(f"path is forbidden: {relative_path}")
        if path.name in FORBIDDEN_FILES or path.name.endswith(".pem"):
            raise ValueError(f"file is forbidden: {relative_path}")
        return path

这个实现同时挡住几类风险:

  • ../../secret.txt 这类路径逃逸;
  • .git.venvnode_modules 这类高噪声或敏感目录;
  • .envagent.config.toml.pem 这类 secret 文件。

路径沙箱不能依赖 prompt。模型可以被提示词约束,但真正的边界必须由代码执行。

局部编辑优先

MVP 没有提供任意 write_file,而是提供 replace_in_file

def replace_in_file(ws: Workspace, path: str, old: str, new: str) -> ToolResult:
    try:
        file_path = ws.resolve(path)
        text = file_path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError, ValueError) as exc:
        return ToolResult(False, "", str(exc))
    if old not in text:
        return ToolResult(False, "", "old text not found; read the file again before editing")

    file_path.write_text(text.replace(old, new, 1), encoding="utf-8")
    return ToolResult(True, f"updated {path}")

这会逼模型先 read_file,再基于精确片段修改。它不能随手重写整个文件,也更容易保护用户未提交的局部改动。

生产系统还可以把编辑工具升级成 patch 工具:

  • 要求 unified diff;
  • 校验 patch 是否只影响允许路径;
  • 显示 diff 让用户确认;
  • 自动检测大范围格式化;
  • 对并发修改做冲突检查。

Shell 白名单

Shell 是 Coding Agent 最危险的工具之一。本章的实现只允许配置中的命令:

DEFAULT_ALLOWED_COMMANDS = {
    "python",
    "python3",
    "pytest",
    "ruff",
    "mypy",
    "npm",
    "pnpm",
    "make",
    "go",
}

同时拦截危险 token:

DENY_TOKENS = {
    "chmod",
    "chown",
    "curl",
    "git",
    "rm",
    "scp",
    "ssh",
    "sudo",
    "wget",
}

这不是最终安全模型,但已经足够表达原则:让 Agent 运行测试,不等于让 Agent 拥有一个无限 shell。


23.10 Policy Engine:把审批从 Prompt 里拿出来

policy.py 把工具分成三类:

READ_ONLY_TOOLS = {"list_files", "read_file", "search_code", "git_diff"}
WRITE_TOOLS = {"replace_in_file", "create_file"}
SHELL_TOOLS = {"run_shell"}

决策逻辑很小:

class Policy:
    def __init__(self, auto_edit: bool = False, auto_shell: bool = False):
        self.auto_edit = auto_edit
        self.auto_shell = auto_shell

    def decide(self, call: ToolCall) -> str:
        if call.name in READ_ONLY_TOOLS:
            return "allow"
        if call.name in WRITE_TOOLS:
            return "allow" if self.auto_edit else "ask"
        if call.name in SHELL_TOOLS:
            return "allow" if self.auto_shell else "ask"
        return "deny"

MVP 里 ask 只是返回“需要审批”,还没有做人机交互。但这个状态很重要,因为生产系统通常会把 ask 接到:

  • CLI 确认;
  • IDE 弹窗;
  • Web 审批流;
  • Slack / Teams 审批;
  • 企业策略中心。

不要让模型自己决定“这个操作安全吗”。模型可以解释风险,但最终裁决必须在确定性系统里完成。


23.11 Agent Loop:把模型、工具和状态串起来

agent.py 是系统主循环。

def run_agent(
    task: str,
    repo_root: str | Path,
    llm: LLMClient,
    max_steps: int = 20,
    auto_edit: bool = False,
    auto_shell: bool = False,
    allowed_commands: list[str] | None = None,
    shell_timeout: int = 30,
    trace_writer: TraceWriter | None = None,
) -> AgentState:
    root = Path(repo_root).resolve()
    ws = Workspace(root)
    policy = Policy(auto_edit=auto_edit, auto_shell=auto_shell)
    state = AgentState(task=task, repo_root=str(root))

每一轮做六件事:

  1. 构造 prompt;
  2. 调用模型;
  3. 解析 JSON;
  4. 判断 final 或 action;
  5. 经过 Policy 执行工具;
  6. 记录 step 和 trace。

核心片段如下:

for _ in range(max_steps):
    prompt = build_prompt(state, root)
    raw = llm.complete(prompt)
    data = parse_model_output(raw)
    thought = str(data.get("thought", ""))

    if "final" in data:
        final = data["final"]
        state.status = "done"
        state.final = final
        for path in final.get("changed_files", []):
            state.changed_files.add(str(path))
        _record_step(state, AgentStep(thought=thought or "done", final=final), trace_writer)
        return state

    if "action" not in data:
        _record_step(
            state,
            AgentStep(
                thought=thought or "invalid model response",
                tool_result=ToolResult(False, raw, str(data.get("error", "missing action or final"))),
            ),
            trace_writer,
        )
        continue

注意两个细节。

第一,模型输出无效时,系统不会崩溃,而是把错误写回 observation。下一轮模型仍有机会修复。

第二,max_steps 是硬停止条件。任何 Agent Loop 都必须有预算,不能让模型无限尝试。

当前 MVP 的一个刻意简化

这个版本在收到 final 时会把 state.status 设为 done。生产系统里更稳妥的做法是让 final 进入 verifier:

model final
  │
  ▼
run verifier
  ├─ pass -> done
  └─ fail -> append observation and continue / failed

本章保留 verifier.py,是为了明确下一步演进方向:完成状态不能只由模型声明,应该由测试、diff 和风险检查共同决定。


23.12 LLM Adapter:隔离模型供应商

llm.py 定义了一个很窄的接口:

class LLMClient(Protocol):
    def complete(self, prompt: str) -> str:
        ...

本地测试使用 FakeLLM

class FakeLLM:
    def __init__(self, responses: list[str]):
        self.responses = responses
        self.index = 0

    def complete(self, prompt: str) -> str:
        if self.index >= len(self.responses):
            return json.dumps(
                {
                    "thought": "no more responses",
                    "final": {
                        "summary": "stopped",
                        "verification": "none",
                        "changed_files": [],
                    },
                },
                ensure_ascii=False,
            )
        value = self.responses[self.index]
        self.index += 1
        return value

真实运行使用 DeepSeek 兼容接口:

payload: dict[str, object] = {
    "model": self.config.model,
    "messages": [{"role": "user", "content": prompt}],
    "temperature": self.config.temperature,
    "max_tokens": self.config.max_tokens,
    "response_format": {"type": "json_object"},
}

为什么不把模型 SDK 直接散落在 agent.py 里?

因为生产系统一定会遇到这些变化:

  • 模型供应商切换;
  • 模型版本灰度;
  • 请求超时和重试;
  • JSON mode 或 tool calling 能力差异;
  • token 成本统计;
  • prompt / response 日志脱敏;
  • fallback 模型;
  • eval 环境固定模型。

模型只是 Agent Runtime 的一个依赖,不应该成为整个系统的中心。


23.13 Trace:让每一步都能复盘

trace_writer.py 把每个 AgentStep 写成 JSONL:

class TraceWriter:
    def __init__(self, path: str | Path):
        self.path = Path(path)
        self.path.parent.mkdir(parents=True, exist_ok=True)

    def write_step(self, step: AgentStep) -> None:
        with self.path.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(asdict(step), ensure_ascii=False) + "\n")

运行后会生成类似路径:

traces/20260506-143000.jsonl

一条 trace 记录可能长这样:

{
  "thought": "I need to inspect the divide function first.",
  "tool_call": {
    "name": "search_code",
    "args": {
      "query": "def divide",
      "pattern": "*.py"
    }
  },
  "tool_result": {
    "ok": true,
    "content": "calculator.py:1: def divide(a: int, b: int) -> float:",
    "error": ""
  },
  "final": null
}

Trace 至少有五个用途:

  • 单次失败调试;
  • 找出工具误用模式;
  • 生成 eval case;
  • 对比不同 prompt、模型和 policy;
  • 审计高风险任务。

如果没有 trace,Agent 的失败就只能靠猜;有了 trace,失败可以被分类、回归和修复。

在更完整的系统里,trace 还应该能直接生成 eval case 候选。比如一次 Coding Agent 失败 trace 显示:模型修改了 calculator.py,但没有新增除零测试,最后只根据 final answer 宣布完成。这条 trace 可以转成回归样本:

eval_case:
  id: "coding-agent-missing-regression-test-001"
  source_trace: "traces/20260506-143000.jsonl"
  task: "给 divide 函数补充除零错误处理,并添加 pytest 测试"
  required_behavior:
    - "修改业务代码"
    - "新增或更新测试覆盖 b == 0"
    - "运行 pytest 并记录结果"
  forbidden_behavior:
    - "未运行验证命令就宣布完成"
    - "只有代码修改,没有测试修改"
  scoring:
    - diff_contains_test_change
    - verifier_passed
    - final_answer_has_evidence

这就是第 17 章生产治理控制面在最小 Coding Agent 里的落点:Trace Writer 负责留下事实,Eval Runner 和 Release Gate 负责让旧失败不能静默复发。


23.14 Verifier:不要让模型自己宣布完成

verifier.py 提供了一个最小验证器:

def verify(
    ws: Workspace,
    commands: list[str],
    allowed_commands: list[str] | None = None,
) -> tuple[bool, str]:
    outputs: list[str] = []
    for command in commands:
        result = run_shell(ws, command, timeout=60, allowed_commands=allowed_commands)
        outputs.append(f"$ {command}\n{result.content}\n{result.error}".strip())
        if not result.ok:
            return False, "\n\n".join(outputs)

    diff = git_diff(ws)
    if not diff.content.strip():
        return False, "no code changes detected"
    return True, "\n\n".join(outputs)

最小 verifier 检查两件事:

  1. 验证命令是否通过;
  2. git diff 是否非空。

生产系统可以继续加:

  • lint、typecheck、unit test、integration test;
  • 快速测试和全量测试分层;
  • flaky test 重试策略;
  • diff 风险分类;
  • 代码所有权和影响面分析;
  • secret scan;
  • 静态安全扫描;
  • snapshot 或 golden file 比较;
  • 人类 reviewer checklist。

重要原则是:final answer 是报告,不是证据。证据来自工具结果、测试输出和 diff。

如果把 verifier 接到发布门禁,最小决策可以非常朴素:

def coding_agent_release_gate(eval_report):
    if eval_report.invalid_json_cases > 0:
        return "block", "model output protocol is unstable"

    if eval_report.verifier_failed_cases > 0:
        return "block", "agent claims completion without passing verifier"

    if eval_report.missing_regression_test_cases > 0:
        return "review", "code changes may lack regression coverage"

    if eval_report.avg_tool_calls > eval_report.tool_call_budget:
        return "review", "tool call budget exceeded"

    return "allow", "coding agent candidate passed"

这里的门禁不需要一开始很复杂,但必须由证据驱动:trace 证明发生过什么,verifier 证明结果是否成立,eval report 决定候选 Agent 能不能进入下一阶段。


23.15 概念执行路径:从任务到 diff

以下是一个隔离的演示性代码仓库中可能发生的执行路径,而非本章提供的运行步骤或本仓库的配套示例。任务可以是:为 calculator.pydivide 函数补充除零错误处理,并新增相应测试。

这类具有编辑和 shell 权限的配置只适合一次性沙箱或可丢弃 worktree,不应该作为团队共享仓库或生产代码库的默认策略。

理想执行路径大致是:

1. list_files 或 search_code,找到 calculator.py 和 test_calculator.py;
2. read_file 读取现有实现和测试;
3. replace_in_file 修改 divide;
4. replace_in_file 修改测试;
5. run_shell("pytest");
6. git_diff;
7. final report。

运行时最终报告可以包含:

status: done
changed_files: ['calculator.py', 'test_calculator.py']
trace: traces/20260506-143000.jsonl

--- step 1 ---
...

--- git diff ---
...

如果模型没有按理想路径执行,trace 就是调试入口。

常见失败与修复

现象可能原因修复方向
模型输出不是 JSONprompt 约束不足,模型不支持 JSON mode强化输出协议,换支持 JSON mode 的模型
一上来就编辑系统规则不够硬加 “read before edit” 到 prompt 和 eval
old text not found文件已变或模型引用片段不精确让模型重新 read_file 再改
shell 被拒绝命令不在 allowlist修改配置或让模型选择允许命令
测试失败后直接 finalverifier 未接入 final gate把 final 改为 verifier pass 后才能 done
diff 过大模型重写文件或格式化全文件禁用 write_file,限制 replace 范围
读到 secretforbidden 文件规则不够扩展 FORBIDDEN_FILES 和 secret scan

Agent 工程的改进方式不是“再劝模型认真一点”,而是把失败映射到 prompt、context、tool、policy、verifier 或 eval 的具体层。


23.16 测试策略:先测 Runtime,再测模型效果

一个实际项目的测试套件应故意不依赖真实模型。以工具层测试为例,应覆盖:

  • 正常读取文件;
  • 阻止路径逃逸;
  • 阻止读取 forbidden 文件;
  • 精确替换文件;
  • shell allowlist;
  • git_diff 只读。

配置层测试应覆盖:

  • TOML 解析;
  • 默认配置;
  • shell allowlist;
  • 模型配置字段。

Agent Loop 测试可用 FakeLLM 驱动:

llm = FakeLLM(
    [
        json.dumps(
            {
                "thought": "create a note",
                "action": {
                    "name": "create_file",
                    "args": {"path": "note.txt", "content": "hello\n"},
                },
            }
        ),
        json.dumps(
            {
                "thought": "review diff",
                "action": {"name": "git_diff", "args": {}},
            }
        ),
        json.dumps(
            {
                "thought": "done",
                "final": {
                    "summary": "created note",
                    "verification": "git diff reviewed",
                    "changed_files": ["note.txt"],
                },
            }
        ),
    ]
)

这类测试的价值是验证 Runtime 可靠,而不是验证某个模型“聪明”。

模型效果需要另一类 eval:

- id: divide_zero_guard
  task: "给 divide 函数补充除零错误处理,并添加测试"
  expected:
    changed_files:
      - calculator.py
      - test_calculator.py
    must_run:
      - pytest
    must_not:
      - read agent.config.toml
      - run rm

生产级 Coding Agent 通常要同时有两套评估:

  • Runtime tests:确定性、快速、无模型;
  • Agent evals:端到端、带模型、可回归。

23.17 从 MVP 演进到生产级 Coding Agent

这个概念案例已经有了核心骨架,但离生产级系统还有明显距离。演进时建议按风险顺序,而不是按功能诱惑。

阶段一:Read-only Agent

只开放:

  • list_files;
  • read_file;
  • search_code;
  • git_diff

目标是让 Agent 能回答:

  • 这个需求可能涉及哪些文件;
  • 代码当前怎么工作;
  • 应该怎么改;
  • 需要哪些测试。

这是接入真实大仓库最安全的第一步。

阶段二:Patch Agent

开放局部编辑,但不开放 shell。

重点治理:

  • path sandbox;
  • precise replace;
  • diff preview;
  • 人工确认;
  • 禁止 secret 和生成文件误改。

这一阶段适合让 Agent 生成小 patch,由人运行测试。

阶段三:Verified Agent

开放测试类 shell 命令。

重点治理:

  • command allowlist;
  • timeout;
  • stdout / stderr 截断;
  • flaky test 标记;
  • 失败重试预算;
  • final gate 接入 verifier。

目标是形成“修改 -> 测试 -> 修复 -> 报告”的闭环。

从这一阶段开始,建议引入最小治理控制面:

  • 每次任务保存 trace;
  • verifier 输出进入 eval result;
  • 失败 trace 可以转成 regression eval case;
  • release gate 阻断“未验证即完成”“修改无 diff”“测试缺失”等历史失败。

阶段四:Workflow Agent

加入计划和任务状态。

适合更长任务:

  • 模块迁移;
  • API 重构;
  • 测试补全;
  • 依赖升级;
  • lint 批量修复。

此时需要:

  • plan step 状态;
  • 可暂停和恢复;
  • checkpoint;
  • 每步验收标准;
  • 中途汇报。

阶段五:Skill-enabled Agent

引入 Skill Registry,把重复工程流程沉淀成可版本化资产。

典型 Skill:

Skill触发场景验证要求
bugfix失败测试、异常日志、线上 bug先复现,后修复,必须补回归测试
test-writing补测试、提升覆盖率先读现有测试风格,覆盖失败路径
refactor模块整理、接口迁移保持行为等价,小步验证
dependency-upgrade升级库或运行时查 breaking changes,跑兼容测试
release-check发布前检查只读检查优先,高风险动作审批

Skill 是过程记忆,不是权限系统。它能告诉 Agent 怎么做,但不能让 Agent 绕过 Policy。

阶段六:Team Agent

接入团队工程流:

  • PR 创建;
  • CI 结果读取;
  • reviewer agent;
  • code owner;
  • branch sandbox;
  • issue / ticket 状态同步;
  • 企业审计;
  • eval gate。

这个阶段的核心问题不再是“模型会不会写代码”,而是“它如何进入团队责任链”。


23.18 生产级风险清单

Coding Agent 一旦能写文件和运行命令,就必须严肃处理风险。

文件风险

  • 路径逃逸;
  • secret 文件读取;
  • 生成文件误改;
  • 大范围格式化;
  • 覆盖用户未提交修改;
  • 误改二进制文件。

推荐措施:

  • workspace sandbox;
  • forbidden path;
  • text suffix allowlist;
  • diff size limit;
  • dirty worktree warning;
  • patch preview。

命令风险

  • 删除文件;
  • 网络下载和执行;
  • 修改权限;
  • 推送远程分支;
  • 运行生产脚本;
  • 超时或资源耗尽。

推荐措施:

  • command allowlist;
  • deny token;
  • timeout;
  • cwd 限定;
  • 网络默认关闭;
  • 高风险命令审批。

模型风险

  • invalid JSON;
  • hallucinated file path;
  • 未读文件直接编辑;
  • 测试失败仍声称成功;
  • 被外部文档 prompt injection;
  • 长上下文遗忘约束。

推荐措施:

  • schema validation;
  • read-before-edit eval;
  • verifier gate;
  • tool result 标注可信度;
  • prompt injection guardrail;
  • max steps 和 cost budget。

组织风险

  • 责任不清;
  • 自动合并过快;
  • 审计缺失;
  • 模型版本变化导致行为漂移;
  • eval 覆盖不足。

推荐措施:

  • agent version;
  • release gate;
  • trace retention;
  • PR reviewer;
  • regression eval;
  • rollback plan。

23.19 设计评审和项目实践表达

如果你把这套设计做成自己的项目实践,不要只说“我做了一个能改代码的 Agent”。更好的表达是:

我设计了一套可观测 Coding Agent Runtime 方案。

它不是让模型直接操作文件系统,而是通过 Runtime 把模型输出限制为 JSON action。
Runtime 负责做 policy check、路径沙箱、工具执行、trace 写入和 diff 输出。

第一版支持 read/search/edit/shell/diff 七类工具,其中写文件只能通过精确 replace,shell 只能运行 allowlist 里的验证命令。
每一步都会写入 JSONL trace,因此可以复盘模型为什么做这个操作、工具返回了什么、哪一步失败。

我还写了不依赖真实模型的单元测试,用 FakeLLM 验证 Agent Loop。
这保证了控制面本身可测,而不是把所有稳定性交给模型。

后续如果要生产化,我会优先补 verifier final gate、patch preview、人类审批、eval dataset、模型版本灰度和 OpenTelemetry trace。

评审者真正想听的不是“用了哪个模型”,而是你是否理解 Agent 系统的工程边界:

  • 模型负责推理;
  • Runtime 负责执行;
  • Policy 负责权限;
  • Tools 负责确定性副作用;
  • Verifier 负责完成证据;
  • Trace 负责复盘;
  • Eval 负责持续改进。

23.20 构建检查清单

如果你要从零实现自己的 Coding Agent,可以按下面清单验收。

Runtime

  • 是否有 AgentState
  • 是否有最大步数?
  • 是否能处理 invalid JSON?
  • 是否区分 action 和 final?
  • 是否记录每一步?

Context

  • 是否加载项目规则?
  • 是否构建 repo map?
  • 是否避免注入整个仓库?
  • 是否截断过长工具结果?
  • 是否能把最近 observation 放回下一轮?

Tools

  • 是否有工具注册表?
  • 是否只允许相对路径?
  • 是否阻止路径逃逸?
  • 是否过滤 secret 文件?
  • 是否优先局部替换?
  • shell 是否有 allowlist 和 timeout?

Policy

  • 是否区分 read、write、shell?
  • 是否支持 allow、ask、deny?
  • 高风险动作是否能进入人工审批?
  • policy decision 是否进入 trace?

Verification

  • 是否运行验证命令?
  • 是否捕获 stdout / stderr?
  • 是否检查 diff?
  • final 是否必须带验证证据?
  • 验证失败是否能回到 Agent Loop?

Observability

  • 是否保存 JSONL trace?
  • trace 是否包含 tool call、tool result、final?
  • 是否能从失败 trace 生成 eval case?
  • 是否记录模型、prompt、工具和配置版本?

Production

  • 是否有 eval dataset?
  • 是否有 Failure Registry 记录高影响失败?
  • 是否有 release gate?
  • 是否支持灰度和回滚?
  • 是否默认 read-only?
  • 是否有 secret scan?
  • 是否能降级到人工模式?

本章小结

本章以一个讲解性 Coding Agent 案例梳理了可观测 Runtime 的工程边界;本文不提供配套可运行项目。

它的价值不在于功能复杂,而在于工程边界完整:

  1. Context Builder 控制模型看到什么;
  2. JSON action 协议控制模型如何表达意图;
  3. Tool Runtime 把外部能力变成可审查接口;
  4. Policy Engine 把权限从 prompt 里拿出来;
  5. Agent Loop 维护状态、预算和执行闭环;
  6. LLM Adapter 隔离模型供应商;
  7. Trace Writer 让每一步可复盘;
  8. Verifier 把“完成”变成证据问题;
  9. Tests 先验证 Runtime,再评估模型效果;
  10. Failure Registry 和 Release Gate 让失败进入回归闭环;
  11. 演进路线从 read-only、patch、verified、workflow、skill-enabled 到 team agent。

一句话总结:

生产级 Coding Agent 不是“一个会写代码的模型”,而是一个围绕模型建立的可控执行系统。

如果第 13 章回答的是“成熟 Coding Agent 产品为什么这样设计”,第 14 章回答的是“可嵌入 Coding Agent Runtime 应该长什么样”,本章回答的就是“如何理解并设计这个 Runtime 的最小闭环”。


参考资料

  1. OpenAI Function Calling Guide
  2. Model Context Protocol Specification
  3. Claude Code Hooks - Anthropic Docs
  4. Cursor Rules - Cursor Docs