3386 words
17 minutes
pi-loop:一个最小可跑的循环工程示范

pi-loop:一个最小可跑的循环工程示范#

这个项目是什么#

pi-loop 是一个供初学者拆解的玩具:用 245 行 TypeScript,在 pi 上面搭一个会自己修 bug 的循环。目标程序是一个故意写坏的 Python 小脚本(app/bill.py,公司名带单引号会让 SQL 拼接崩掉),循环的任务是反复跑测试、让模型改代码,直到测试全绿。

它不打算解决生产问题,也没有任何产品化设计。它的价值在于把”循环工程”(Loop Engineering)这件事拆成六块积木,让你能逐块看清每块在干什么、为什么非它不可。看懂这个玩具,你就能理解一个完整 agent 循环的最小骨架长什么样。

如果你刚接触 AI 编码 Agent,只会用单轮对话让模型写一段代码,那这篇文章是写给你的。

循环工程的核心:一个客观信号驱动的反馈环#

单轮对话的问题在于:模型写完一段代码,你不知道它对不对,只能自己读、自己跑。模型也不会自己回头检查。所谓”循环工程”,就是把这个”写完就结束”的链路,接成一个有客观信号、会自我修正的环:

跑测试 → 红 → 喂给模型(带历史) → 模型改代码 → 再跑测试
↑ ↓
└──────────── 还红就再来一轮 ←──────────────────────────┘

这个环能转起来,靠的不是模型多聪明,而是三个东西:

  1. 一个客观的二元判定:测试通过或不通过,不靠模型自评。
  2. 一个能改代码的执行体:有工具,真动手。
  3. 一份跨轮记忆:模型每轮都会忘,得有地方记下之前试过什么。

这三件事在 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)各自是一篇文章的事,本文聚焦让循环真正转起来的核心、Sub-agent、以及把三者串起来的主循环。

两种角色,严格分离#

这是整个项目最关键的结构决定,不是风格选择:

  • 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)轮。每轮的步骤:

  1. feedback():uv run pytest 在目标目录跑一遍,正则看输出里有没有 passed 且没有 failed → 二元 pass/fail。
  2. 绿了就退出。没绿,把 goal + STATE.md 历史内容 + 最新测试输出拼成 prompt,喂给 maker。
  3. Maker 改代码。改完后重新跑一次 feedback()——拿最新信号,不是轮开头那份旧红。
  4. 如果现在绿了 → done(客观信号确认,verifier 跳过,省一次模型调用)。如果还红 → 起 verifier(),把最新红信号给它,它回 PASSFAIL: <原因>
  5. ## 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,新测试是红的。循环有活干了。起循环:

Terminal window
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):

Terminal window
# 仓库根目录
npm install # 装 tsx + typescript
# 目标程序目录
cd app && uv sync # 装 pytest
uv run pytest # 应该是 4 passed 1 failed(或全绿,看当前状态)

起循环(回到仓库根目录):

Terminal window
LOOP_CWD=$(pwd)/app npm run loop # 用默认 goal
LOOP_CWD=$(pwd)/app npm run loop "你自定义的 goal" # 自定义

想看可观测输出就别关 trace(默认开);想安静点就 LOOP_TRACE=0。想看 maker 的思维链和每个工具调用的实时流,trace 开着的时候会直接打到终端。

几个常用环境变量:

变量默认作用
LOOP_CWD当前目录循环操作的目标目录
LOOP_MAX_ITERS4最大轮数,到顶标”需要人类介入”
LOOP_MAKER_MODELglm-5.2maker 模型
LOOP_CHECKER_MODELqwen3.7-maxverifier 模型,故意和 maker 不同
LOOP_TRACE10 静默 maker 流式输出

不需要 ANTHROPIC_API_KEY。驱动器复用 pi /login 已经登好的 provider 凭据。

它故意没做的事#

这是个玩具,边界要讲清楚,免得初学者以为这就是循环工程的全貌:

  • 没有生产级的错误恢复。 Maker 输出格式崩了、工具调用失败,没有重试逻辑,直接进下一轮。
  • “同一错误连犯三次就停”只是 skill/SKILL.md 里的一条生产规则,驱动器没实现。 循环可能在一个死胡同里把 MAX_ITERS 用光。
  • Worktree 隔离靠扩展挂载,默认 npm run loop 不启用。 平行跑多个 maker 会互相覆盖文件。
  • Verifier 的 RUBRIC 是硬编码的。 想换评判标准要改源码,这是故意的。
  • 目标程序只有一个文件、一个表、四个测试。 真实项目里测试套件庞大、反馈周期长,这个玩具不模拟那种情况。

这些缺口每一条都是可以扩展的练习题。看懂这个最小骨架之后,挑一条补上,你就开始真正理解循环工程了。

它想教会什么#

pi-loop 的价值落在三件事的体感:

  1. 判对错的不能是模型自己。 一个独立运行的测试进程、一个换模型的只读评委,都是为了把”对错”从模型嘴里夺出来,交给客观信号。
  2. 跨轮记忆落在磁盘,不落在上下文。 模型每轮都失忆,仓库不会。这是从”单轮对话”走向”循环”必须跨过的一道坎。
  3. 循环的终点是客观信号变绿,不是模型说”我做完了”。 这一条把”AI 自评”和”外部验证”的差别摆到台面上。

理解了这三点,再看市面上那些复杂的 agent 框架,你会发现它们的骨架和这个玩具是一样的,只是多了调度、重试、并行、监控这些工程化外套。骨架清楚了,外套就好读了。

pi-loop:一个最小可跑的循环工程示范
https://sgjki547.top/posts/2026-07-13-pi-loop-最小可跑的循环工程示范/
Author
SGJki
Published at
2026-07-13
License
CC BY-NC-SA 4.0