1. 为什么 AI 编程代理需要一套“工程控制层”
1.1 Pi Coding Agent 的定位和现实的短板
最近很长一段时间,我大部分时间都在跟各类 AI Coding Agent 打交道。从补一个函数、修一个 bug,到直接让代理生成一个完整模块,效率提升确实肉眼可见。但用多了之后,我逐渐意识到一个很现实的问题:它们解决的是“快”,而不是“稳”。
Pi Coding Agent 这类工具的定位很明确——它能理解你的项目结构,能调用各种工具去读文件、跑测试、改代码,甚至可以在一次会话里完成多步开发任务。这就像一个非常熟练的实习生:脑子转得快,手上活也快,但你敢不敢让它直接操作生产环境的分支,敢不敢让它自动 deploy,那是另一回事。
问题出在哪里?出在它原生缺少一套“工程化护栏”。你去观察一个成熟的研发流程,不管是 GitOps 还是传统的 CI/CD,各个环节都有明确的可观测节点、可恢复点和编排策略。构建失败了有日志可查,发布出问题能回滚,流程走到某个关卡必须人工审批。但这些能力,大多数 AI Coding Agent 本身是不自带的。它们默认你就是“在旁边盯着的那个人”,出了问题你会知道,代码写坏了你会自己处理。
当代理只做一次性答疑、生成代码片段的时候,这个前提还能成立。可一旦你想让它长时间、自动地参与真实项目开发,比如连续盯一个 issue,跨多个文件改代码,最后推分支、建 MR,这一套“默认有人兜底”的假设就撑不住了。
1.2 控制层的三个能力,对应的是什么问题
我给 Pi Coding Agent 补齐的那层东西,本质上是在代理和真实研发流程之间,加了一层可编程的控制面。我不去改代理本身的推理能力,而是接管它执行过程中的关键节点,让整个运行过程变成“可被观测、可被恢复、可被编排”的工程流程。
拆开来说,三个能力对应三个具体痛点:
可观测:代理执行过程中到底发生了什么,中间状态是什么,有没有偏离任务目标,有没有做出预料外的操作。你不可能等它跑完了再看结果,因为它可能已经改了十几个文件。我见过太多次“代理跑完很开心,diff 一看凉半截”的场景。
可恢复:当任务失败、代理陷入死循环、或者对环境做出了不可控的改动时,能不能快速回到一个稳定的中间状态。这不是简单的“ctrl+c 重来”,因为代码文件、环境状态、agent 内部的上下文都已经变了。可恢复意味着有一套明确的检查点机制,让“失败”变成流程里的一个普通分支,而不是事故。
可编排:把一个模糊的大任务拆成有先后顺序、有质量门槛的阶段,允许人在关键节点介入审批,允许并发任务共享资源但互相隔离。没有编排,代理是“放养”状态;有了编排,代理变成了流水线上的一环。
我管这套控制面叫 Pi-Harness。Harness 这个词在工程领域一直有“控制装置、保护装置”的意思,比如飞行器的线束系统、赛车里的安全约束系统。它不替代引擎,但保证引擎在任何状态下都不会失控。
1.3 Pi-Harness 的整体架构视图
从宏观架构上看,Pi-Harness 位于 Pi Coding Agent 与外部工具链(Git、Shell、测试框架、CI/CD)之间,以独立进程或边车(sidecar)的方式运行。
它的核心模块我大致分为四块:
| 模块 | 职责 | 对应能力 |
|---|---|---|
| Event Collector | 采集代理的事件流、工具调用、状态快照 | 可观测 |
| State Store | 保存检查点、上下文快照、任务状态 | 可恢复 |
| Policy Engine | 执行门禁、审批、重试、降级等策略 | 可编排 |
| Control API | 对外暴露操作接口,让 CI/CD 或人工介入 | 可编排 |
这几个模块之间的数据流是单向的:代理产生的每一个动作事件先进 Event Collector,归一化之后写入 State Store,Policy Engine 根据当前状态决定下一步动作,最终通过 Control API 下发指令。这样做的最大好处是,控制层不依赖代理的内部实现,只要代理能输出事件流、能接收指令,整套机制就成立。
2. 可观测:把代理的“思考过程”变成可回放的数据
2.1 可观测的第一原则:记录“意图”而不只是“结果”
传统的服务可观测性,核心是记录“请求-响应”链路上的数据,看延时长不长、错误率高不高、堆栈长什么样。但代理运行时的可观测性跟它有一个本质区别:代理的中间过程本身就是高价值的。
例如,代理收到任务“把登录模块的错误提示改成统一风格”。结果上看,它可能改对了。但过程里它先尝试读了一个不存在的新版设计规范文件,然后自己退回去猜了一个方案,最后改了 3 个文件。如果只看结果,你根本不知道它那一次失败的探索会带来多大的不确定性。下次再遇到类似任务,你要不要信任它?
所以第一原则是:不只要记录“做了什么”,还要记录“为什么这么做”。也就是给每个工具调用补充上下文,包括当前任务目标、模型输入输出摘要、代理自己的推理片段。不需要保存全部的 token 级数据,但必须能回答“这个改动的意图是什么”这个问题。
2.2 观测数据的四层模型
我在 Pi-Harness 里把观测数据分成了四层,每一层有不同的采集频率和存储策略。
调用层(Tool Call Level):记录每一次工具调用的名称、参数摘要、耗时、返回状态。这是最基础的数据,相当于传统 RPC 的访问日志。采集频率最高,但单条数据量很小。
上下文层(Context Level):记录代理当前的工作目录、相关 git 分支、已读取文件的重要片段、模型输出中的关键决策信息。这一层回答“它是在什么认知状态下做出决策的”。
环境层(Environment Level):记录 shell 环境变量、依赖版本、当前进程资源占用、文件系统的关键变更。很多看起来毫无逻辑的失败,最后都是环境差异导致的。
结果层(Result Level):记录每个阶段结束后的产物,比如 git diff、测试报告、lint 结果、本次任务产出的文件清单。这一层通常对接 CI/CD 的上下游。
每一层我都用不同的 TTL 和保留策略。调用层保留最近 7 天足够排查问题;上下文层保留到任务结束后的 30 天;结果层作为审计记录长期保留。别一上来就所有数据都存 30 天,存储成本和查询成本都受不了。
2.3 事件流设计示例和与 OTel 的对接
Pi-Harness 的采集核心是一个事件流。每一次代理动作,都会被包装成一条结构化事件,写入事件管道。下面是一个归一化后的事件结构,贴合常见 agent 工具协议的抽象:
{ "event_id": "evt_a1b2c3d4", "trace_id": "tr_9f8e7d6c", "session_id": "ss_01h2xy", "timestamp": "2025-01-20T15:04:22.123Z", "type": "tool_call", "tool": "git_rebase", "action": "dry_run", "input_summary": { "branch": "feature/login-refactor", "onto": "main", "conflict_policy": "abort" }, "policy_version": "v18" }这里有几个设计点值得说。
trace_id是对接 OpenTelemetry 的关键。我会在任务启动时生成一个根 span,覆盖整个代理会话,每个工具调用作为一个子 span。这样你可以把 agent 的运行轨迹和下游服务日志关联起来。input_summary不是完整入参,而是一个“足够判断意图”的摘要。比如 git rebase 不需要把完整 diff 都塞进事件里,但需要记录分支、目标和冲突策略。这能有效控制事件体积。policy_version是 Pi-Harness 自己的设计,我会记录当时生效的策略规则版本。否则等几周后回顾问题时,你会发现“当时为什么允许它这么做”这个问题根本查不了,因为策略可能已经改了几轮。
对接 OTel 的时候,我会把工具调用的状态映射成标准的 span status,再附加一个agent.workdir的 attribute。这样 Grafana 或 Jaeger 里可以直接按工作目录过滤,非常适合排查一个仓库里多个并行任务互相干扰的问题。
2.4 实操心得:哪些观测项最容易被忽略
第一项是代理的“放弃点”。很多时候代理遇到困难不会直接报错,而是绕一个很大的弯子最后放弃。你需要记录它是在哪一步放弃的、之前失败了多少次、最后一次失败原因是什么。我见过一个代理连续重试同一项操作 27 次,每次都是同样的权限错误,因为工具本身没有明确的失败传播机制。如果没有记录重试次数和失败 pattern,这种问题根本发现不了。
第二项是代理对错误信息的解读过程。代理看到测试失败之后,它如何解读失败信息、选择了什么修复路径,这是判断它是否具备调试能力的重要依据。我在事件流里单独用一个type: "reasoning"的事件来记录这类信息,不追求完整推理原文,只保留关键结论。
第三项是文件系统的变更明细。代理可能在你看不到的地方创建了大量临时文件,或者在某一次 shell 命令中悄悄修改了一个重要的配置文件。只在最后拿 diff 是远远不够的,我需要实时感知文件系统的变化,特别是在非预期路径下的写入。实现上可以挂一个基于 inotify 的监听,或者让代理上报每个 shell 命令的工作目录。
3. 可恢复:让失败成为流程中的一个普通分支
3.1 为什么恢复能力不能事后补救
用代理开发最怕的从来不是代理出错,而是出错之后“回不去”。传统开发里你写坏了几行代码,git checkout 一下就回去了。但代理运行过程中,可能同时改了代码、安装了依赖、启动了服务、变了环境变量,甚至提交了 commit。你事后想恢复,根本没有单一机制能处理全部副作用。
我的原则是:恢复能力必须前置设计。也就是在代理开始干活之前,我知道有哪些风险操作,哪些状态需要打快照,哪些步骤失败之后安全的恢复点在哪。等到出问题再临时想办法,多半已经迟了。
这也是为什么 Pi-Harness 会拦截所有的“高影响工具调用”,比如文件批量写、git push、依赖安装、服务重启。这些操作不是禁止执行,而是必须先经过检查点确认。
3.2 检查点设计:位置、内容、存储
检查点的选择遵循三个位置原则:
- 阶段边界:plan 阶段结束、implement 阶段刚开始的节点,通常是天然恢复点。
- 高风险操作前:在代理准备执行 git push、rm -rf、pip install 这类命令之前。
- 状态突变前:比如代理要切换分支、降级依赖版本、修改全局配置,这些操作会把系统带入一个新的状态,切换前必须打点。
检查点内容可以分成三包:
- 代码状态包:当前分支、HEAD commit、工作区 diff、暂存区状态。
- 会话状态包:代理的上下文快照、已完成步骤清单、当前任务的剩余目标。
- 环境状态包:关键环境变量、依赖版本锁文件、正在运行的进程列表。
存储上我采用双写模型:本地写一份,供快速恢复;远程对象存储写一份,供跨机器迁移或审计。序列化格式用内容寻址的存储,也就是以内容哈希作为文件名。这样不仅天然去重,还能通过对哈希判断某个状态是否已经被快照过,避免重复打点。
3.3 恢复动作的三种路径:重试、回滚、补偿
恢复动作不是只有“重来一遍”。不同场景要用不同策略。
**重试(Retry)**适用于幂等操作。前提是我在事件流里明确记录了工具的幂等性。比如 git fetch、跑测试、lint 检查,这些重试不会产生副作用。但 git commit、文件覆盖、发送请求这类非幂等操作,不能简单重试。我会在工具定义上标记idempotent: true/false,Pi-Harness 根据标记决定是否允许自动重试。
**回滚(Rollback)**适用于“回到最近一个稳定检查点”。实现上我会先恢复代码状态包,再恢复环境状态包。顺序很重要,必须先代码后环境,避免代码恢复了但环境依赖不匹配,导致代理根本无法起项目。回滚之后,Pi-Harness 不会立即让代理继续跑,而是先让代理重新感知一下当前状态,再做一次快速的 dry-run 验证。
**补偿(Compensation)**适用于副作用无法用回滚解决、需要反向操作的情况。典型案例是代理已经 push 了一个分支,你要回滚不是删 remote 分支那么简单,而是逆操作掉那批 commit,或者创建一个 revert commit。我会把补偿动作也放进策略文件里,由 Policy Engine 匹配执行。
3.4 Checkpoint Coordinator 实现思路
我实现的 Checkpoint Coordinator 核心逻辑不复杂,但有一个坑:它必须能响应外部信号,而不是依赖代理主动汇报。因为代理可能卡死、可能崩溃、可能忽略你的指令。Coordinator 里有一个独立的 watchdog 线程,定期检查事件流的活跃度。
class CheckpointCoordinator: def __init__(self, store, watcher): self.store = store self.watcher = watcher self.active_session = None async def on_tool_start(self, event): tool = event["tool"] if self._need_checkpoint_before(tool): snap = await self._snapshot_session(event["session_id"]) await self.store.save(snap, reason=f"before:{tool}") def _need_checkpoint_before(self, tool: str) -> bool: risk_rules = { "git.push": 1, "file.bulk_write": 1, "shell.install_deps": 1, "service.restart": 1, } return risk_rules.get(tool, 0) >= 1恢复接口是显式的,我提供了一个restore命令:
pi-harness restore --session ss_01h2xy --checkpoint cp_hash_xxx --strategy rollback这条命令会:
- 停止当前代理会话的事件接收。
- 根据检查点哈希,从本地或远程存储拉取状态包。
- 按“代码 → 环境 → 会话”的顺序恢复。
- 将代理上下文重置到检查点记录的目标状态。
- 以
--resume-from参数重启代理会话。
这里有个细节:重启后的代理不会“记得”它做过什么,但它会看到文件系统恢复到了检查点状态。如果你在会话状态包里保留了“当前步骤”字段,并把它作为新会话的初始 prompt 补充信息,代理就能无缝续跑。
3.5 实操心得:恢复方案最怕什么
恢复方案最大的敌人是状态包的完整性。你 90% 的时间都在处理“明明恢复了,但为什么行为就是不对”的问题。排查到最后,往往是某个环境变量、某个临时目录里的隐式状态没有被快照进去。
第二个经验是:恢复策略不能自动化到全无感知。我最初做得很激进,所有失败都自动回滚,结果有时候代理是在正确轨道上做探索性修改,一回滚反而丢了进展。后来我把恢复动作分为自动和需要确认两种:代码状态包的回滚可以自动,环境状态包的回滚必须人工确认。原因很简单,环境恢复动作影响范围可能超出当前任务,自动执行风险太高。
第三个心得涉及磁盘清理。检查点如果频繁打,本地存储膨胀很快。我最终采用的策略是:本地只保留最近 3 个检查点,远端保留最近 30 个。恢复时优先本地,本地没有再从远端拉。这个取舍让我既保证了快速恢复,又不会把开发机磁盘打爆。
4. 可编排:把代理放进受控流水线里
4.1 编排的核心是“阶段门禁”
提到编排,很多人第一反应是工作流引擎,比如 Temporal、Airflow。但放到 AI Coding Agent 的场景里,我认为核心并非复杂的 DAG 依赖,而是“阶段门禁”(Stage Gate)。
阶段门禁的意思是:把一个开发任务拆成若干阶段,每个阶段有明确的输入、输出和质量门槛,只有通过门槛才能进入下一个阶段。Pi Coding Agent 的执行过程默认是连续的,它会自己决定什么时候做完了、什么时候该停。这种方式在简单任务里没问题,但复杂任务会让决策链条很长,出了问题很难定位是哪一步引入的。
我的编排模型固定为四个阶段:
| 阶段 | 主要动作 | 门禁条件 |
|---|---|---|
| plan | 分析需求、列出文件改动清单、设计实现方案 | 方案里每个文件改动都有明确理由;无高风险操作 |
| implement | 逐文件编码、重构 | 所有目标文件已变更;无计划外文件被改 |
| verify | 跑测试、lint、构建、静态分析 | 测试通过率达标;关键质量指标无回退 |
| review | 汇总 diff、生成变更摘要、提交 MR | 变更描述完整;无遗留 TODO;无敏感信息泄漏 |
每个门禁检查都有一个回调钩子。Pi-Harness 在阶段边界暂停代理执行,等待门禁结果。门禁可以由策略自动判定,也可以由人工审批,或者两者结合。
4.2 任务分解与上下文隔离
可编排的另一个核心问题是任务分解。Pi Coding Agent 接受一个模糊需求时,往往会直接开始干。但我希望把它拆成更小、更可控、更快反馈的单元。
我在 Pi-Harness 里加入了一个 Planner 组件。它的作用不是替代代理的规划,而是在代理开始执行前,把任务拆成“阶段单元”,并且限定每个单元的工作范围。例如,任务“优化商品列表页性能”会被拆成:
- 读取现有商品列表页的实现代码,确定瓶颈指标。
- 用 profiler 找出耗时瓶颈。
- 以最小改动优化热点代码。
- 运行性能对比测试。
每个单元都有独立的临时目录、独立的环境变量、独立的目标文件范围。这样做的好处有两个:一是如果任务 3 失败,回滚时只在单元 3 的范围内操作,不会影响任务 1、2 的成果;二是可以控制并发度,让多个单元并行执行而不互相污染。
上下文隔离的实现上,我把每个单元包装成一个“sandboxed workspace”,包含:
- 独立的 git worktree 或浅克隆。
- 独立的日志和临时目录。
- 独立的环境变量前缀。
这样即使代理误执行了rm -rf /tmp/xxx,影响的也只是它这个单元自己。
4.3 并发、沙箱和资源限制
多任务并行是编排能力的重要体现,但也最容易出事。最典型的问题是多个代理同时操作同一个仓库、同一个本地端口,导致互相踩踏。
我的做法是给每个代理实例分配独立的资源配额,通过 cgroup 限定 CPU 和内存,通过命名空间隔离网络端口映射。比如 Pi-Harness 配置里可以声明:
execution: concurrency: 4 per_agent: cpu_limit: "2.0" memory_limit: "2Gi" network: "isolated" workspace: mode: "per_branch"per_branch模式会让每个任务直接分包到独立分支,避免了 commit 冲突。这个模式我实测下来对高频并行的场景很重要,否则你会在 merge 阶段发现一大堆本不该出现的冲突。
沙箱的隔离性不必追求虚拟机级别的完整。对大多数开发任务,进程级别的隔离足够。真正要盯的是网络和文件系统这两类副作用。文件系统可以通过确保每个任务有自己的工作根目录来解决,网络则要看任务是否需要访问内网依赖。如果任务只需要拉取公开依赖,我会直接启用“无外联”的网络策略,又安全又隔离。
4.4 与 CI/CD 的集成场景
Pi-Harness 不应该是一个孤立工具,它必须能嵌入已有的工程链路。我在落地时主要做了两类集成。
第一类是事件透传。Pi-Harness 产生的每个阶段结果事件,我都会转成标准通知,送到团队的 IM 群和工单系统。这样代理的执行过程不是黑盒,各个角色可以在阶段边界看到进展。实现上我走了 Webhook,事件格式沿用了前述的可观测事件结构。
第二类是触发 CI 流水线。在 verify 阶段,Pi-Harness 需要把代码变更交给 CI 跑一套全量测试。我不会在本地直接跑所有测试,因为本地环境跟 CI 环境总是有差异。我在 verify 门禁里配置了一个钩子,调用 CI 的 API 提交一个构建任务,拿到结果之后再决定是否进入下一阶段。
与 CI/CD 的衔接最需要关注的是“任务身份传递”。从 Pi-Harness 发起的 CI 任务,必须带上有意义的标签,比如task_id、session_id、phase_name。否则你在 CI 平台看到几百个构建任务时,根本分不清哪个是哪个。我就是因为前期没带标签,排查一个线上故障时花了大量时间对映射关系。
4.5 实操心得:编排器和代理的边界
编排器的核心边界是:代理负责“怎么做”,编排器负责“可不可以做、什么时候做”。两者不要互相越界。
我早期犯过一个错误:在编排逻辑里过度干预代理的行为细节,比如强制指定每个函数怎么写、每一步 follow 什么风格。结果代理的产出僵硬,失去了灵活性。后来改成只做边界管控:
- 不限制代理的具体实现方式,但限制它不能修改哪些文件。
- 不强制代理的顺序,但要求必须在计划范围内。
- 不要求代理按固定模板产出,但强制进行阶段门禁校验。
这个边界明确之后,编排层变得轻量,代理的创造力也保住了。遇到门禁失败时,Pi-Harness 会把门禁失败原因完整回传给代理,让代理自行调整。只有在多次失败或明显偏离目标时,控制层才会接管并触发恢复策略。
5. 实操:搭建一个最小可用的 Pi-Harness
5.1 项目骨架与配置
我讲一下怎么在本地最快搭一个最小可用的 Pi-Harness。这里的代码是贴合常见 agent 工具协议的最小骨架,你可以根据 Pi Coding Agent 的实际运行入口做替换。整体目录结构如下:
pi-harness/ ├── pyproject.toml ├── pi_harness/ │ ├── __init__.py │ ├── config.py │ ├── collector.py │ ├── checkpoint.py │ ├── orchestrator.py │ └── store.py ├── policies/ │ └── default.yaml └── examples/ └── basic_run.py配置文件我放在policies/default.yaml,核心内容:
project: name: demo-service repo: git@github.com:example/demo-service.git sessions: default_branch: main temp_dir: /tmp/pi-harness checkpoint: local_keep: 3 remote_keep: 30 provider: local phases: - name: plan gates: - type: confirm_no_high_risk_tools - name: implement gates: - type: check_target_files_changed - type: deny_unplanned_files - name: verify gates: - type: run_test_suite - type: run_lint - name: review gates: - type: check_summary - type: detect_secrets tools: git_push: { checkpoint: before, idempotent: false } file_bulk_write: { checkpoint: before, idempotent: false } shell_install: { checkpoint: before, idempotent: true } test_runner: { checkpoint: none, idempotent: true }这个配置传达了一个关键信息:所有策略都是声明式的。我不想把行为逻辑写死在代码里,因为策略会随项目演进频繁调整,写死在代码里每次都要发版太痛苦。
5.2 关键实现:事件采集器
事件采集器的核心是拦截代理的每一次工具调用。最稳的方式不是去试图理解代理内部的完整决策过程,而是对运行入口做一层包装。
# collector.py import json import time import uuid class EventCollector: def __init__(self, sink): self.sink = sink async def record_tool_call(self, ctx, tool_name, input_params, result): event = { "event_id": uuid.uuid4().hex, "trace_id": ctx.trace_id, "session_id": ctx.session_id, "timestamp": time.time(), "type": "tool_call", "tool": tool_name, "input_summary": self._summarize(input_params), "output_summary": self._summarize(result), } await self.sink.append(event) def _summarize(self, data): # 关键:不是全量记录,而是把大字段截断汇总 if isinstance(data, dict): return {k: self._truncate(v) for k, v in data.items() if self._visible(k)} return self._truncate(data) def _truncate(self, value, limit=120): text = str(value) return text[:limit] + "..." if len(text) > limit else text_summarize这段是真正影响实用性的地方。如果不做摘要,一次复杂的工具调用参数动辄几十 KB,事件存储会快速膨胀;但摘要做太狠又看不到决策信息。我最终取了个经验值:结构化的关键字段保留完整值,非结构化的大文本截断到 120 字符。
5.3 关键实现:检查点协调器
检查点协调器的实现要点是:它不直接操作 git 命令,而是调用一个 Snapshotter 模块。Snapshotter 负责把“当前状态”变成可存储的快照。
# checkpoint.py import subprocess import hashlib import json from pathlib import Path class Snapshotter: def __init__(self, workspace, store): self.workspace = workspace self.store = store def take(self, reason): snapshot = { "code": self._git_snapshot(), "env": self._env_snapshot(), "session": self._session_context(), "reason": reason, "created_at": time.time(), } content = json.dumps(snapshot, ensure_ascii=False, default=str) digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] self.store.save(digest, content) return digest def _git_snapshot(self): head = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip() diff = subprocess.check_output(["git", "diff"]).decode() return {"head": head, "diff": diff} def _env_snapshot(self): return { "cwd": str(self.workspace), "python_version": subprocess.check_output(["python", "--version"]).decode().strip(), "critical_env": {k: v for k, v in os.environ.items() if k.startswith("PI_")}, }我特别想提醒的一点是:git diff 只是代码快照的一部分,你还需要记录“当前处于哪个分支”“有没有未跟踪的新文件”。未跟踪文件经常被忽略,但它们往往是代理生成的配置或脚本,恢复时如果缺失,可能影响下一次运行。我把未跟踪文件单独打一个 tar 包存到检查点里,虽然占空间,但完整恢复时心里踏实。
5.4 关键实现:阶段编排器
阶段编排器的职责是按配置的阶段列表推进任务,并在阶段边界执行门禁。
# orchestrator.py class PhaseOrchestrator: def __init__(self, phases, gate_checker, agent_runner): self.phases = phases self.gate_checker = gate_checker self.agent_runner = agent_runner async def run(self, session): for phase in self.phases: session.current_phase = phase.name await self.agent_runner.run_until(session, phase_goal=phase.name) gate_results = await self.gate_checker.check(phase.gates, session) if not gate_results.ok: await self._handle_gate_failure(session, phase, gate_results) return False await self.agent_runner.pause_briefly(session) return Truerun_until是编排层向代理层发出的指令,表示“你继续干活,但到该阶段的边界就停下来”。这里的接口依代理的实现不同而有差异,但核心语义是一致的:编排器不打断代理的内部节奏,只在阶段边界检查质量。
_handle_gate_failure我实现了三档:
- 失败信息可自动修正:将失败原因回传给代理,允许再试一次。
- 失败需要人工判断:将该阶段事件推送到 IM 群,等待人工确认。
- 持续失败:自动触发检查点恢复,并降低任务的自动执行权限。
这个三层回退策略在日常使用中非常实用,能覆盖大部分情况,又不至于让代理无边界地自嗨。
5.5 配置示例:一个完整的两阶段演示
下面是一个真实的运行场景示例。代理要在 demo-service 里修复一个测试失败,Pi-Harness 控制只允许它修改src/目录下的文件,不允许它动配置文件。
project: name: demo-service allowed_targets: - "src/**" denied_targets: - "config/**" - "deploy/**" checkpoint: before_high_risk: true gates: after_implement: - type: assert_target_files - type: assert_no_denied_change after_verify: - type: run_test_suite - type: lint_summary在这个配置下,就算代理非常想顺手改一下 deploy 配置,也会在 implement 阶段的门禁被拦下来,并记录一条不满足策略的事件。记录这条事件的价值很大,因为下一次你在排查“为什么代理的 MR 没有按预期合并”时,就能看到它其实“想做”一个被禁止的改动,而不是它“已经做了”却没被发现。
6. 常见问题与踩坑记录
6.1 高频问题速查表
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 代理明明执行了文件写入,但检查点里没有记录 | 事件采集器没接住 shell 类工具的输出 | 检查事件流里type=file_change是否记录;为 shell 工具增加 cwd 捕获 |
| 恢复检查点后代理仍然表现异常 | 环境状态包不完整 | 对比恢复前后的 env diff;检查未跟踪文件是否被忽略 |
| 门禁通过但 CI 上测试失败 | 本地依赖与 CI 不一致 | 用锁文件固定版本;让 verify 阶段直接在 CI 容器内执行 |
| 多个代理并发时互相抢占端口 | 网络隔离未生效 | 切换到per_branch模式;为每个会话分配独立端口段 |
| 恢复太频繁,任务推进缓慢 | 检查点策略过激进 | 调整高风险操作清单,降低触发频率;区分“必达检查点”和“可选检查点” |
| 代理执行中事件流中断 | 代理侧异常退出 | 给代理 runner 加 watchdog;事件流补一次末端 flush |
| 策略改动后旧任务行为不一致 | 策略版本未记录到事件里 | 确保所有事件都带policy_version;改动策略前打一个版本 tag |
这张表基本囊括了我两个人从零搭建支撑一个小团队时遇到的主要问题。前两个问题是最容易踩的,我分别展开说一下。
6.2 我在落地时踩过的三个坑
第一个坑是把事件采集做成了“代理主动上报”模式。代理正常时还好,一旦代理内部被模型输出卡住或者进程崩溃,事件就丢了。后来改成双向模式:Pi-Harness 通过包装代理的工具调用接口来主动采集,同时代理也上报一些额外元信息。这样即使代理进程异常退出,至少已经执行的工具调用事件都留下来了。
第二个坑是检查点恢复后,代理的上下文没有同步。我起初以为把文件恢复到检查点状态就万事大吉,但代理内部的“多轮对话上下文”仍然停留在崩溃前的状态,于是它会接着之前的逻辑继续执行,但文件却已经回到了更早的版本,两边一错位,代理会做出非常奇怪的动作。后来我强制在每次恢复之后启动一个“快速对齐”环节,让代理先读取几个关键文件,确认自己身处的新状态之后再干活。
第三个坑是编排门禁跑得太重。我想着既然要门禁,就把静态检查、安全扫描、全量测试全都塞进去,结果一个简单改动也要等七八分钟才能推进一个阶段。代理干活的节奏被拖得很粉碎,反而不如在核心门禁之后做一次批量校验。这个教训让我重新理解了“阶段门禁”的粒度:门禁是为了挡住明显错误,不是为了替代所有质量保障手段。轻量门禁快速反馈,重量校验只在 review 阶段统一跑,这个取舍让整个流程的吞吐量提升非常明显。
6.3 还有一个容易被忽视的细节:代理会话的“年龄”
长时间运行的代理会话会出现上下文累积的问题。模型会把很早之前读过的文件内容仍然当作重要信息,导致决策被过时信息影响。我在 Pi-Harness 里为会话设置了一个“年龄上限”,比如超过 200 次工具调用或 30 分钟就建议你开一个新会话续跑,而不是让同一个代理一直工作下去。
有人可能觉得频繁切会话会丢失进展,其实只要有检查点在,切会话不是问题。我建议在每次阶段门禁通过后都主动做一次 session 重置,让代理带着当前任务摘要和最新文件状态重新出发。这样既避免上下文污染,又能让每个阶段的推理更加专注。这个习惯改变之后,代理在长任务里的成功率明显提高了。
我个人在这些反复试错中最深的体会是:给 AI Coding Agent 做控制层,真正难的从来不是写那几段拦截和控制代码,而是搞清楚你在真实研发流程里到底要保护什么。保护代码状态、保护环境一致性、保护代理决策的可追溯性,这三件事做到位了,控制层自然会有价值。反过来说,如果只是想“让代理更听话”,那不管接多少层 harness,也很难真正解决工程组织的信任问题。