一个 while 只够 agent 活 5 分钟:learn-agent 的 s01–s15 机制与踩坑
一个 while 循环是 agent 的全部秘密——但只够它活 5 分钟。剩下的部分——让它在真实任务里活过 5 小时——是 UniversePeak/learn-agent 这组笔记记的东西。仓库从 0 开发 agent 的踩坑记录出发,每篇配一份零依赖、单文件、直接能跑的代码,每篇解决一个真实翻车现场。机制不是照 API 文档想象,而是从完整开源的桌面 coding agent Reina(Electron + React + TypeScript)简化移植而来,每一条报错、每个机制优化都是线上踩过的坑。
项目信息
- 笔记仓库:UniversePeak/learn-agent
- 完整实现:Reina-Agent/Reina
- License: MIT, © 2026 7-e1even
30 秒跑起来,Node 18+,任何 OpenAI 兼容 key 都行(DeepSeek / Kimi / GLM / OpenRouter / 本地 Ollama):
git clone https://github.com/7-e1even/learn-agent && cd learn-agent
AGENT_API_KEY=sk-xxx node s01_agent_loop/agent.mjs
没有 key:s12 的自测模式不需要任何 key 也能端到端跑通全部机制。入口是 s01_agent_loop/agent.mjs。
能跑的 demo 与能活的 agent,差在哪
循环在第 1 篇写完,之后尽量不改——所有机制都围着它长。每篇结构一致:踩过的坑 → 设计决定 → 可跑代码走读 → 真实产品对照 → 动手挑战。
s01 证明 agent 和 chatbot 的全部区别是一个 while;s02 加工具不改循环;s03 循环预算与自动纠偏处理复读机、原地踏步、连环报错;s04 开始管每轮往模型眼睛里灌多少;s05 修 Ctrl+C 之后坏掉的消息序列;s06 压缩上下文但不丢最初任务;s07 做缓存命中工程;s08 会话落盘与恢复;s09 子代理与心跳看门狗;s10 System prompt 组装;s11 多 agent 协作;s12 合体;s13 权限与审批;s14 Provider 兼容层;s15 渐进式工具披露。
下面挑两个最常翻车的位置:观测预算和权限。
s04 · 一条 cat 就能爆窗:工具输出预算与无损溢出
场景很具体:让 agent「看看这个导出文件有什么问题」,它 cat 一个 2MB 压缩 JSON。三种坏结局:
- 爆窗:2MB≈五十多万 token,直接 400;
- 变贵:就算 1M 窗口吞下,这 50 万 token 之后每一轮都重新计费,账单翻倍;
- 粗暴截断丢信息:s02 给
read_file打的 50KB 截断创可贴,截掉的部分模型永远看不到,它甚至不知道自己错过了什么。
根源是模型的观测没有预算。循环预算(s03)管「跑多少轮」,s04 管「每轮往眼睛里灌多少」。
截断和溢出的区别在于信息是否还有救:
| 截断 | 溢出 | |
|---|---|---|
| 超限部分 | 删除,永远消失 | 搬家,完整落盘 |
| 模型是否知道错过什么 | 不知道 | 知道,指针写明全文在哪、多大、怎么取 |
| 需要细节时 | 只能重跑命令(贵且可能不可重现) | read_file 分段取回,一次一小口 |
指针三要素是「在哪 / 多大 / 怎么取」。溢出后回给模型的是头尾节选 + 指针:
[输出超过预算:全文共 2097212 字符,已完整保存到 .agent-spill\1783040027411-7dc4a787.txt。
需要被省略的部分时,用 read_file(配 offset/limit)分段读取,不要重跑命令。]
「不要重跑命令」这句必须有,否则模型第一反应是把 cat 再跑一遍。
预算要两层:单条上限 + 整轮总量。只设「单条不超过 50KB」防不住——一轮十个调用每条 30KB 就是 300KB。溢出按 largest-first 执行:从最大的开刀性价比最高,溢出一条 60KB 通常整轮就回预算内,剩下 30KB 原文保留。够了就收手。这层聚合预算在真实产品里还有个现实动机:MCP 这类外部工具的输出根本不经过你的单条截断。
read_file 是特例(spillable:false):读出来的东西本来就在磁盘上,再落盘一份副本纯属浪费。Reina 早期真犯过,后来修掉。它的形状是自我设限:offset/limit 分段读取,尾注告诉模型总量和续读位置。同一思想两种形态:内存里的大输出→落盘+指针;磁盘上的大文件→指针就是它自己。
日志先压缩再计预算。测试/构建日志 95% 是噪声。压缩分三类:anchor(错误/警告/总结行,永远保留+前后 2 行)、trace(堆栈帧,挨着保留行时整块保留,堆栈只有完整才有用)、noise(成片折叠成「… [N 行省略]」)。两条保险:行数太少不碰;省不到 15% 就原样返回(eslint 输出常是 safe no-op)。折叠真发生时全文同样落盘、同样给指针——压缩管「回给模型多少」,落盘保证「一个字都没丢」,整体无损。
运行输出节选:
场景一:单条 2MB 输出 → 原始 2097212 字符,回给模型 2144 字符(压到 0.10%),落盘全文
场景二:一轮 4 条输出合谋超总量 → 整轮 150000 → 92140 字符(预算 100000)
⤵ 溢出 run_shell(git log):60000 → 2140;保留 grep -r / ls -laR / read_file(read_file 不落盘副本)
场景三:vitest 风格日志 394 行 → 13 行,省 97.0%(错误行一行没丢)
Reina 分两层。工具层在 packages/tools/src/utils.ts:每个内置工具输出上限 50KB / 2000 行;shell 输出用尾部截断(exit code 和失败总结在结尾),全文落盘 .reina/tool_outputs/ 附 read_file 指针。引擎层在 packages/core/src/engine.ts 的 enforceTurnObservationBudget:单条超 100,000 字符或整轮超 200,000 字符时 largest-first 溢出(小窗口模型按窗口 15%/30% 缩放);另有 24,000 字符紧急裁剪,只在即将撞破窗口时出手,是有损最后手段但连它也带指针。日志压缩在 packages/tools/src/log-compress.ts(从 headroomlabs/headroom 的 Rust 实现移植),多一个细节:把数字/十六进制地址归一化后,相邻近似重复行折叠成 (line) ×N;真实 vitest 输出实测省约 65%,eslint 是 safe no-op;REINA_LOG_COMPRESS=0 可整体关闭。Claude Code 同款:Bash 工具输出超 30,000 字符会截断,就是那句 output truncated。
s13 · 权限与审批:在「模型意图」和「真副作用」之间加一道闸
前十二篇的 agent 有一双手(run_shell、write_file)却没约束:模型说 git push 它就 push,说读 .env 它就读。放真项目里会删库、外泄密钥、往生产分支乱推。
两个极端都是错的:什么都不问=没装闸;什么都问=每个调用都弹窗,用户一定会习惯性狂点允许,闸形同虚设。正解是大部分操作按预设规则自动裁决,只有拿不准的才问。
规则链:每条规则形如「什么工具 / 什么命令前缀 / 什么路径 → allow / deny / ask」,从上到下首匹配。
━━━ 场景 A:只有全局规则(首匹配 + 三态)━━━
✅ 放行 run_shell(git status) 命中 [allow "git status…"]
🚫 硬拒 run_shell(git push origin main) 命中 [deny "git push…"]
🚫 硬拒 run_shell(rm -rf node_modules) 命中 [deny "rm -rf…"]
🚫 硬拒 read_file(.env.local) 命中 [deny **/.env*]
✅ 放行 read_file(src/engine.ts) 命中 [allow src/**]
❓ 问用户 run_shell(npm test) 无规则命中 → default
❓ 问用户 write_file(src/new.ts) 无规则命中 → default
三个关键决定:
① 三态不是两态:allow/deny/ask。大量操作属「这次得看情况」,ask 是一等公民,命中 ask 就交还用户(弹一次审批),用户的选择可「记住」,下次升级成 allow。没有 ask,只能在太松和太紧之间二选一。
② 首匹配 + 顺序即优先级:第一个命中的定案,后面不看。危险的 deny 放最上面,安全的 allow 放中间,没人认领的靠链尾 default:"ask" 兜底。
export function evaluatePermission(rules, req, defaultVerdict = "ask") {
for (const rule of rules) {
if (ruleMatches(rule, req)) return { verdict: rule.verdict, rule };
}
return { verdict: defaultVerdict, rule: null };
}
一条规则里可给多个选择器(工具名+命令前缀+路径 glob),都命中才算命中(AND)。命令前缀匹配前先 trimStart()——模型偶尔在命令前带个空格," git push" 不该逃过 deny。
③ workspace 覆盖 global:deny 先于一切,allow/ask 才分层。直觉是把项目规则排全局前面,但这有洞:项目里写一条 allow: git push 就能抢在全局 deny 前命中,项目配置拆掉全局的闸。所以合并时 deny 单独提到链首(不分层级):
export function mergeRules(globalRules, workspaceRules) {
const isDeny = (r) => r.verdict === "deny";
return [
...workspaceRules.filter(isDeny),
...globalRules.filter(isDeny),
...workspaceRules.filter((r) => !isDeny(r)),
...globalRules.filter((r) => !isDeny(r)),
];
}
场景 B:可信 demo 项目预授权 npm test 和写 src/**:npm test 被 workspace 从「问」提升到「放行」,write_file(src/new.ts) 同样放行,但 git push origin main 仍被 global 的 deny 拦住。项目能把操作从「问」提升到「放行」,却提不动 git push 的 deny——所有 deny 合并在链首,workspace 写一条 allow: git push 也排在后面、永远轮不到。放权是加白名单,不是拆闸;这要由合并顺序保证,不能指望项目配置自觉。
接进真实 agent:免 key 版是纯函数;接 s01 循环只是薄薄一层——派发工具之前先 evaluatePermission:allow 直接执行,deny 回一条 observation 告诉模型「这个不许,换个方式」,ask 挂起循环、发审批请求,拿到答复再继续(复用 s05 的中断/恢复)。危险操作的裁决永远发生在副作用之前。
Reina 对应 packages/core/src/permissions.ts:PermissionRule 形状、evaluatePermission 首匹配、ruleMatches 里的 commandPrefix / pathGlob 选择器、自写 globMatches 小匹配器,以及「workspace 规则合并时排在 global 之前」的覆盖语义。生产版还多两件:规则文件按 mtime 缓存(避免每次求值都读盘);shell 命令审批走 shell-approval.ts 做更细的解析(把一行 a && b 拆成多条分别裁决,别让危险命令躲在 && 后面)。ask 对应桌面端审批卡片,点「总是允许」就把这条固化进 workspace 规则。Claude Code 同款思路:allow/deny/ask 三态 + 规则匹配(~/.claude/settings.json 的 permissions.allow/deny,项目级 .claude/settings.local.json 叠加),同样是 deny 无条件优先于 allow——项目级提得动 allow、提不动任何一层的 deny。
动手挑战:给 ask 加「记住选择」时,粒度该是「这条命令」还是「这个前缀」?记太宽(allow run_shell *)等于拆闸,太窄(逐字匹配)等于没记。commandPrefix 是纯前缀匹配,能拦 git push origin main,但拦不住 git push(多空格)或 git push;rm -rf /(拼接命令)——所以要专门写 shell-approval.ts 把命令解析后再逐段裁决。
s01–s15 机制清单
| 编号 | 主题 | 机制 |
|---|---|---|
| s01 | 一个循环,一双手 | agent 和 chatbot 的全部区别是一个 while |
| s02 | 工具箱与调度 | 加工具不改循环;Edit 唯一匹配契约 |
| s03 | 循环预算与自动纠偏 | 复读机/原地踏步/连环报错,先拍肩膀再熔断 |
| s04 | 工具输出预算 + 无损溢出 | 一条 cat 就能爆窗;截断丢信息,溢出到磁盘不丢 |
| s05 | 流式与中断 | Ctrl+C 之后坏掉的消息序列怎么修 |
| s06 | 上下文压缩 | 压缩后不忘最初任务,启动消息逐字保留 |
| s07 | 缓存命中工程 | 前缀稳定性;连压缩摘要那次调用都能省 90% |
| s08 | 会话落盘与恢复 | 断了能接上才叫能用 |
| s09 | 子代理与心跳看门狗 | 卡死检测(闲置 vs 在工具里)、击杀前抢救遗言 |
| s10 | System prompt 组装 | prompt 是每轮拼出来的,不是写死的;skills 按需加载 |
| s11 | 多 agent 协作 | DAG 任务图、同 brief 去重、并发上限 |
| s12 | 合体 | 全部机制回到同一个循环;免 key 端到端自测 |
| s13 | 权限与审批 | 危险操作在副作用前裁决;allow/deny/ask 三态首匹配 |
| s14 | Provider 兼容层 | 模型乱吐 tool call(名字/参数/截断/散文)在边界掰平 |
| s15 | 渐进式工具披露 | 工具多了不撑爆上下文;解蔽别回灌数组、撞缓存 |
完整实现 Reina 对照片段
s01 主循环→core/engine.ts;s03/s04 预算与溢出→core/loop-budget.ts;s06/s07 压缩与缓存→compaction.ts / engine-prompt.ts;s09/s11 子代理与多 agent→subagent/activity.ts / subagent/manager.ts;s13/s14 权限与 Provider 兼容→permissions.ts / providers/tool-compat.ts。
仓库不卖课不引流,只有笔记和代码,MIT。