pi-loop:一个最小可跑的循环工程示范
这个项目是什么
pi-loop 是一个供初学者拆解的玩具:用 245 行 TypeScript,在 pi 上面搭一个会自己修 bug 的循环。目标程序是一个故意写坏的 Python 小脚本(app/bill.py,公司名带单引号会让 SQL 拼接崩掉),循环的任务是反复跑测试、让模型改代码,直到测试全绿。
它不打算解决生产问题,也没有任何产品化设计。它的价值在于把”循环工程”(Loop Engineering)这件事拆成六块积木,让你能逐块看清每块在干什么、为什么非它不可。看懂这个玩具,你就能理解一个完整 agent 循环的最小骨架长什么样。
如果你刚接触 AI 编码 Agent,只会用单轮对话让模型写一段代码,那这篇文章是写给你的。
循环工程的核心:一个客观信号驱动的反馈环
单轮对话的问题在于:模型写完一段代码,你不知道它对不对,只能自己读、自己跑。模型也不会自己回头检查。所谓”循环工程”,就是把这个”写完就结束”的链路,接成一个有客观信号、会自我修正的环:
跑测试 → 红 → 喂给模型(带历史) → 模型改代码 → 再跑测试 ↑ ↓ └──────────── 还红就再来一轮 ←──────────────────────────┘这个环能转起来,靠的不是模型多聪明,而是三个东西:
- 一个客观的二元判定:测试通过或不通过,不靠模型自评。
- 一个能改代码的执行体:有工具,真动手。
- 一份跨轮记忆:模型每轮都会忘,得有地方记下之前试过什么。
这三件事在 pi-loop.ts 里分别叫 feedback()、PiRpc(maker)、STATE.md。代码里把它们标成”三条命脉”,缺一个就只是链式调用,称不上循环。
六块积木
循环工程是个通用说法,落地到具体 agent 框架需要选六类构件。pi-loop 在每一块上都做了一个最小选择,列表如下:
| 积木 | 角色 | 在本项目里的落点 |
|---|---|---|
| Automation | 谁来触发一轮 | extensions/cron-loop.ts,监听一个文件,变了就开一轮 |
| Worktree | 每轮在哪改 | extensions/worktree-per-turn.ts,每会话一个 git worktree |
| Skill | 项目约定 | skill/SKILL.md,用 pi --skill ./skill 加载 |
| Connector | 怎么和 pi 通信 | PiRpc 类,pi --mode rpc 的 JSONL 子进程 |
| Sub-agent | 谁来交叉验证 | verifier() 函数,独立 pi -p 调用 |
| Memory | 跨轮怎么接力 | STATE.md + pi 的 session.jsonl |
后面四块(Automation/Worktree/Skill/Memory)各自是一篇文章的事,本文聚焦让循环真正转起来的核心
两种角色,严格分离
这是整个项目最关键的结构决定,不是风格选择:
- Maker(生成者):
pi-loop.ts里的PiRpc类。用pi --mode rpc起一条持久子进程,stdin 写命令、stdout 读事件流。它有工具(read/bash/edit/write),真能改代码;它的session.jsonl是跨轮长期记忆。 - Verifier(评分者):
pi-loop.ts里的verifier()函数。用pi -p(print 模式)起一次单次调用,不加载工具(天然只读)、换一个不同模型(qwen3.7-max,对比 maker 的glm-5.2)、每次都是干净上下文。
分离靠的是两种不同的 pi 调用方式:持久 RPC 会话带工具 = maker;无状态 print 调用无工具 = verifier。Maker 永远不批自己的作业——这是避免”模型自己说自己做对了”这种自评失真的硬隔离。
为什么换模型?同一家模型在同类任务上的失败模式高度相关。Maker 用
glm-5.2改完,如果再用glm-5.2当评委,它容易顺着自己之前的思路给 PASS。换qwen3.7-max做交叉验证,能戳穿一部分这类自洽幻觉。
一轮到底怎么转
主循环是一个 for,最多跑 MAX_ITERS(默认 4)轮。每轮的步骤:
- 跑
feedback():uv run pytest在目标目录跑一遍,正则看输出里有没有passed且没有failed→ 二元 pass/fail。 - 绿了就退出。没绿,把 goal +
STATE.md历史内容 + 最新测试输出拼成 prompt,喂给 maker。 - Maker 改代码。改完后重新跑一次
feedback()——拿最新信号,不是轮开头那份旧红。 - 如果现在绿了 → done(客观信号确认,verifier 跳过,省一次模型调用)。如果还红 → 起
verifier(),把最新红信号给它,它回PASS或FAIL: <原因>。 - 把
## iter N+ verdict 追加进STATE.md。Verifier 说 PASS 且测试绿 → 退出。否则进下一轮。
这里有两条容易踩错的细节,代码注释里专门点了出来:
第一,verifier 看的是 maker 改完后的最新结果,不是轮开头的旧红信号。 早期版本图省事,直接把轮开头的测试输出喂给 verifier,结果 verifier 在评判一个已经被修掉的问题,判断和现实脱节。
第二,最终话事人是 feedback()(pytest 客观信号),不是 verifier 的嘴。 Verifier 说 PASS 但测试还红 = verifier 在撒谎,继续循环;测试绿了就 done,不必盲信 verifier。一个外置 LLM 评委的价值在于交叉验证,不在于替代客观信号。把这点写死的代价是:即便 verifier 报 PASS,只要 pytest 还红,循环不停。这避免了”评委和选手串通”的风险。
三条命脉各自长什么样
命脉一:反馈信号 feedback()
function feedback(): { pass: boolean; out: string } { try { const out = execSync("uv run pytest", { encoding: "utf-8", cwd: CWD }); const pass = /passed/.test(out) && !/failed/.test(out); return { pass, out }; } catch (e: any) { return { pass: false, out: String(e?.stdout ?? e?.message ?? e) }; }}它就是跑一次 pytest,看输出里有没有 passed、有没有 failed。pytest 非零退出也会带 stdout,失败是正常的循环信号,所以 catch 里把输出捞回来当红信号喂给模型,而不是当异常抛掉。这一段是整个循环区别于”让模型写一段代码然后结束”的根本:判对错的不靠模型,靠一个独立运行的测试进程。
命脉二:独立验证器 verifier()
async function verifier(goal, rubric, testOut): Promise<string> { const prompt = `You are an independent code reviewer. Be skeptical.Goal: ${goal}Rubric (must ALL hold): ${rubric}Latest test feedback:${testOut}Reply with EXACTLY one line: "PASS" if the goal is met and tests pass, or"FAIL: <one-sentence root cause>" otherwise. No other text.`; // pi -p,prompt 走 stdin 不走 argv const p = spawn(PI_BIN, ["-p", "--provider", PROVIDER, "--model", CHECKER_MODEL], ...); p.stdin.write(prompt); p.stdin.end(); // 90s 超时,被杀时返回 FAIL 不崩 ...}两个实现细节是踩坑换来的:
- Prompt 走 stdin,不走 argv。
pi -p的命令行参数方式遇到长 prompt 或特殊字符会让进程崩。走 stdin 才稳。 - 带 90 秒超时。 被信号杀掉时返回
FAIL,不让循环卡死。Verifier 挂了等于这轮判 FAIL,进下一轮继续,不会让整个循环僵死。
评分配的是一个硬编码的 RUBRIC:"uv run pytest passes (all green); no syntax errors; diff <= 200 lines"。不做成环境变量,是故意的——评判标准不该被运行时随便改,改了就不是同一个循环了。第三条”diff ≤ 200 行”是给 maker 上的一道闸:防止它顺手大重构,把”修一个 bug”演成”重写半个文件”。
命脉三:磁盘状态 STATE.md
function loadMemory(): string { return existsSync(STATE_FILE) ? readFileSync(STATE_FILE, "utf-8") : "(no prior attempts)";}function saveMemory(iter: number, verdict: string) { appendFileSync(STATE_FILE, `\n## iter ${iter}\nverdict: ${verdict}\n`);}每轮开始前,loadMemory() 读出历史,塞进 maker 的 prompt 里,告诉它”之前试过这些,别重复”。每轮结束,saveMemory() 把这轮 verdict 追加进去。只追加,不重写,不改历史条目。
为什么这么在意磁盘?因为模型没有跨轮记忆。每轮 maker 进来,上下文是空的,它不知道上一轮自己试过什么、为什么没成。人也是这样——会忘,但写在白板上就不会。STATE.md 就是那块白板。下次循环从文件接着读,不指望模型自己记着。
一次真实运行的轨迹
光讲结构不跑一遍是空的。我给目标程序加了一个故意失败的新测试,然后起循环,看它怎么走。
目标程序的 save_company 现在能正常存名字。我加了个测试,要求空名字必须抛 ValueError:
def test_empty_name_rejected(): with pytest.raises(ValueError): save_company("")跑测试,4 passed 1 failed——DID NOT RAISE ValueError,新测试是红的。循环有活干了。起循环:
LOOP_CWD=$(pwd)/app npm run loop \ "reject empty company name: calling save_company with an empty string must raise ValueError"实际输出(trace 默认开,会实时把 maker 的工具调用和思维链打到 stderr):
[pi-loop] iter 0: feedback red (0.3s) # 跑 pytest,1 failed,不绿,进 maker[pi-loop] iter 0: prompting maker… # 起 pi RPC,喂 goal+历史+测试输出 tool ▸ read bill.py # maker 第 1 个工具:读文件 tool ◂ read → bill.py 内容 tool ▸ edit save_company # maker 第 2 个工具:改代码 tool ◂ edit → Successfully replaced 1 block agent_end · 2 tool calls · 13.0s # maker 收工[pi-loop] iter 0: maker done, re-running tests… # 重跑 feedback 拿最新信号[pi-loop] iter 0: feedback now green ✓[pi-loop] passed at iter 0 — 客观信号已确认Maker 实际只改了 bill.py 两行,在 save_company 开头加了:
if not name: raise ValueError("company name must not be empty")干净的单一根因修法。这次 loop 第 0 轮就成,所以verifier 根本没被叫起来——feedback 已经绿,不需要交叉验证,省了一次模型调用。这正是代码里 feedback green → done 这条短路的设计目的。
要看 verifier 真正出场,得造一个 maker 第一轮改不对的场景(比如让它修一个需要 schema 变更的问题,它只动了函数体没动表结构),那时 maker 改完仍红,verifier 才会被叫起来报 FAIL: <原因>,循环进下一轮。这个玩具留了这种曲折场景作为练习。
为什么走 RPC,不走 SDK
这是项目里最折腾的一段,值得单独说。pi 的自定义 provider(本项目用的 alibaba-cloud,由 pi-alibaba-models 这个 package 在 session_start 时通过 pi.registerProvider 注册)只在 pi 进程完整启动后才可用。
如果用 SDK 的 createAgentSession 路径,modelRegistry.find("alibaba-cloud", ...) 在 session 创建前会返回 undefined——provider 还没注册,模型找不到,text_delta 事件回来是空的。maker 看上去在跑,实际一个字都没生成。
RPC 路让 pi 进程自己完整启动、加载 packages、注册 provider、用 settings.json 里的默认模型,绕开了这个时序问题。代价是要自己处理 JSONL 子进程的 stdin/stdout、行缓冲、孤儿进程回收——但至少能跑通。项目里的 probe-*.ts 是当初用 SDK 路径的探针脚本,留作对照,不在 tsconfig.json 的编译范围里,跑它们能复现那个空 text_delta 症状。
这条教训写进了项目的 MEMORY.md,是踩坑换来的。
自己跑一遍
环境备好(uv 管 Python,tsx 管 TypeScript):
# 仓库根目录npm install # 装 tsx + typescript
# 目标程序目录cd app && uv sync # 装 pytestuv run pytest # 应该是 4 passed 1 failed(或全绿,看当前状态)起循环(回到仓库根目录):
LOOP_CWD=$(pwd)/app npm run loop # 用默认 goalLOOP_CWD=$(pwd)/app npm run loop "你自定义的 goal" # 自定义想看可观测输出就别关 trace(默认开);想安静点就 LOOP_TRACE=0。想看 maker 的思维链和每个工具调用的实时流,trace 开着的时候会直接打到终端。
几个常用环境变量:
| 变量 | 默认 | 作用 |
|---|---|---|
LOOP_CWD | 当前目录 | 循环操作的目标目录 |
LOOP_MAX_ITERS | 4 | 最大轮数,到顶标”需要人类介入” |
LOOP_MAKER_MODEL | glm-5.2 | maker 模型 |
LOOP_CHECKER_MODEL | qwen3.7-max | verifier 模型,故意和 maker 不同 |
LOOP_TRACE | 1 | 设 0 静默 maker 流式输出 |
不需要 ANTHROPIC_API_KEY。驱动器复用 pi /login 已经登好的 provider 凭据。
它故意没做的事
这是个玩具,边界要讲清楚,免得初学者以为这就是循环工程的全貌:
- 没有生产级的错误恢复。 Maker 输出格式崩了、工具调用失败,没有重试逻辑,直接进下一轮。
- “同一错误连犯三次就停”只是
skill/SKILL.md里的一条生产规则,驱动器没实现。 循环可能在一个死胡同里把MAX_ITERS用光。 - Worktree 隔离靠扩展挂载,默认
npm run loop不启用。 平行跑多个 maker 会互相覆盖文件。 - Verifier 的 RUBRIC 是硬编码的。 想换评判标准要改源码,这是故意的。
- 目标程序只有一个文件、一个表、四个测试。 真实项目里测试套件庞大、反馈周期长,这个玩具不模拟那种情况。
这些缺口每一条都是可以扩展的练习题。看懂这个最小骨架之后,挑一条补上,你就开始真正理解循环工程了。
它想教会什么
pi-loop 的价值落在三件事的体感:
- 判对错的不能是模型自己。 一个独立运行的测试进程、一个换模型的只读评委,都是为了把”对错”从模型嘴里夺出来,交给客观信号。
- 跨轮记忆落在磁盘,不落在上下文。 模型每轮都失忆,仓库不会。这是从”单轮对话”走向”循环”必须跨过的一道坎。
- 循环的终点是客观信号变绿,不是模型说”我做完了”。 这一条把”AI 自评”和”外部验证”的差别摆到台面上。
理解了这三点,再看市面上那些复杂的 agent 框架,你会发现它们的骨架和这个玩具是一样的,只是多了调度、重试、并行、监控这些工程化外套。骨架清楚了,外套就好读了。