用 Pi 搭一个自我进化的 Agent:让它把踩过的坑写回自己的 skill
上一篇《用 Pi 搭一个自己的 Agent》简单讲了一下怎么用 Pi 搭建一个 Agent。但它不会自己学东西。你踩过的坑、验证过的做法,它不会记住,下次还得你重复踩。
这篇教你怎么让它自己复盘,把有价值的经验写回自己的 skill。
先看 Hermes 怎么做的
Nous Research 的 hermes-agent 把这件事做得比较完整,是唯一带内置学习回路(built-in learning loop)的 agent:
it creates skills from experience, improves them during use, nudges itself to persist knowledge
它的做法是让 agent 一边干活一边复盘,把这次会话学到的东西写回自己的 skill 文件。整条链路都在主仓库里,四个文件串起来。
什么时候复盘,看 agent/turn_finalizer.py 的 finalize_turn(),它挂在每轮对话的尾巴上。判断依据不是「聊了几轮」,而是 _iters_since_skill >= _skill_nudge_interval,距离上次复盘过了多少次工具迭代。这个区别有意义:你连着问五个问题它一个工具没调,不值得复盘;一轮里翻了三十个文件改了八个,值得。
计数够了就调 agent/background_review.py 的 spawn_background_review_thread()。它在守护线程里 fork 出一个完整的 AIAgent,继承主 agent 的 provider、模型和凭据,但工具被白名单卡死,只剩记忆和 skill 管理那几个。复盘 agent 碰不到你的代码。
复盘 agent 唯一的写入口是 tools/skill_manager_tool.py 里的 skill_manage(),动作有 create / edit / patch / delete / write_file / remove_file。所有对 SKILL.md 的改动都从这一个函数走。
skill 如果写歪了有 agent/curator.py 的 maybe_run_curator() 兜着,按不活跃时长把 skill 从 active 降到 stale、再降到 archived。它只归档不删除,钉住的 skill 还豁免。自动生成的东西总得留一条捞回来的路。
还有个细节值得记:这些改动本轮都不生效。tools/memory_tool.py 的注释里写明,进系统提示词的是一份冻结快照,「the snapshot remains stable for the entire session (prefix-cache invariant holds)」。不是实现偷懒,是为了不打断前缀缓存。也不算完全冻死,上下文压缩之后 invalidate_system_prompt() 会重新从磁盘读一次。
注意整条链路的终点是什么:skill_manage() 写一个 Markdown 文件。没有梯度,没有训练,没有 GPU。所谓「自我进化」说白了就是一个 agent 读自己的经历、改自己的 Markdown。朴素,但改动看得见、能回滚,也不挑模型,这几点微调给不了。
拆成三个零件
抛开 Hermes 的具体实现,任何一套进化回路都是这三件事,跟你用什么 runtime 无关:
- 什么时候触发。太频繁则每次都在为琐事写笔记,太稀疏则学到的东西早忘了。Hermes 用工具迭代数当代理指标,衡量的是这一轮折腾得厉害不厉害。
- 复盘产出什么。最容易糊弄过去的一环。产出必须能复用,下次遇到同类情形照着做就行。「要仔细检查边界条件」不算,那是废话;「这个项目的
config.load()在测试环境返回null而不是抛错,判空再用」才算。 - 写回哪里。得是下次会话真会被读到的地方。Pi 里就是 skill 目录:
~/.pi/agent/skills/、~/.agents/skills/,项目级的.pi/skills/、.agents/skills/。写到别处它下次读不到。
第二件最难,难在 prompt 不在代码。Hermes 把它写死在 agent/background_review.py 的 _SKILL_REVIEW_PROMPT 里,让复盘 agent 只盯四类信号:
User corrected your style, tone, format, legibility, or verbosity
User corrected your workflow, approach, or sequence of steps
Non-trivial technique, fix, workaround, debugging path, or tool-usage pattern emerged
A skill that got loaded or consulted this session turned out to be wrong, missing a step, or outdated
前三条是「学到了新东西」,第四条是「原来记的东西错了」。第四条最容易漏掉,可只增不改的知识库迟早会有一条过时记录把 agent 带沟里。下面那个实现代码只有四十行,反复调的是这段 prompt。
在 Pi 上搓 self-evolve.ts
挂在 agent_settled,不是 agent_end
Pi 这两个事件都存在,名字看着像一对,行为差得远。官方文档说得很直白:
agent_endfires when that run ends, but Pi may still auto-retry, auto-compact and retry, or continue with queued follow-up messages. Useagent_settledfor status integrations that need to know Pi will not continue running automatically.
翻译成人话:agent_end 对应的是一次底层 run,一轮对话里如果发生了自动重试或者自动压缩后重跑,它会触发好几次。agent_settled 才是一轮一次,触发时 ctx.isIdle() 保证为真。
挂错了不会报错,只会让你一轮对话被复盘三遍,写进三条几乎一样的教训。等你发现的时候 skill 文件已经脏了一片。
让另一个 agent 来复盘
内联有个绕不过去的毛病:复盘的是主 agent 自己。同一个上下文窗口,同一套刚刚用来说服自己的框架,回头看自己十分钟前写的东西。这算不上第二双眼睛。
Hermes 不这么干。它的 spawn_background_review_thread() 在 Python 进程里直接 fork 一个 AIAgent,读得到原始会话数据,不需要主 agent 转述。
Pi 内核确实没给这个口子。翻遍 ExtensionAPI,没有 runAgent、没有 spawn,能触发 agent 干活的只有 sendMessage / sendUserMessage,都是往当前会话里注入。官方示例 examples/extensions/subagent/index.ts 倒是有,但它把 --no-session 写死了,子进程永远白板启动,只拿得到你手写的那个 task 字符串。
上一篇装过的社区包 pi-subagents 补上了这块。它除了给模型注册 subagent 工具,还从 pi-subagents/delegation 导出一条事件总线 RPC,扩展可以直接发请求、直接收结果,全程不经过模型:
import {
SUBAGENT_DELEGATION_REQUEST_EVENT,
SUBAGENT_DELEGATION_RESPONSE_EVENT,
type SubagentDelegationRequest,
type SubagentDelegationResponse,
} from 'pi-subagents/delegation'
import { randomUUID } from 'node:crypto'
// 放在 agent_settled 里,替换掉 sendUserMessage 那一段
const request: SubagentDelegationRequest = {
requestId: randomUUID(),
ownerRunId: ctx.sessionManager.getSessionId(),
nodeId: 'self-evolve',
agent: 'reviewer',
task: `复盘这个会话,把可复用的教训写进 ${SKILL_FILE}。`,
context: 'fork',
cwd: ctx.cwd,
toolBudget: { hard: 12, block: ['bash'] },
result: { kind: 'text' },
}
const off = pi.events.on(SUBAGENT_DELEGATION_RESPONSE_EVENT, (data: unknown) => {
const payload = data as SubagentDelegationResponse
if (payload.requestId !== request.requestId) return
off()
})
pi.events.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, request)task 里那段话跟前面内联版的 prompt 是一样的,两步走,先看有没有被推翻的,再看有没有值得新记的。
真正决定成败的是 context 这个字段。
默认值 "fresh" 和官方示例一个效果,子 agent 只拿到 task 字符串。那一轮到底发生了什么,还得主 agent 总结着喂进去。刚想躲开的自我辩护,又从入口请回来了,白折腾一趟。
"fork" 才有意义。它走 SDK 的 SessionManager.createBranchedSession(leafId),把父会话的 JSONL 条目整份物理复制成一个新会话文件,子进程用 --session 接上去。子 agent 看到的是真实的原始记录,但推理链是全新的。同样的事实,重新想一遍。带历史 fork 是这个社区包独有的,官方示例没有这条路。
几个踩过的地方。事件总线不带类型,pi.events.on 收到的 payload 是 unknown,得自己断言收窄。toolBudget 只能按工具名拦(block: ['bash']),拦不了路径,想要 Hermes 那种「只准写这一个文件」还得自己注册一个作用域只有那个文件的工具。文档另外写明这类请求只能从事件回调里发,别在另一个工具的 execute() 里递归调。还有 npm 上有三个名字相近的包,pi-subagents、pi-sub-agent、@tintinweb/pi-subagents,是不同的东西,别装错。
代价是 self-evolve.ts 不再是拷进去就能跑的单文件,用的人得先 pi install npm:pi-subagents。所以下面给的仍然是内联那版,这条路留给你真需要第二双眼睛的时候。
代码
// self-evolve.ts
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const SKILL_DIR = join(homedir(), '.pi', 'agent', 'skills', 'lessons')
const SKILL_FILE = join(SKILL_DIR, 'SKILL.md')
const MIN_TOOL_CALLS = 6
const MAX_BYTES = 12 * 1024
const TEMPLATE = `---
name: lessons
description: 过往会话里踩过的坑和验证过的做法。开始编码任务前加载。
---
# 教训本
`
export default function (pi: ExtensionAPI) {
let toolCalls = 0
let touchedCode = false
let reflecting = false
pi.on('tool_result', (event) => {
if (reflecting || event.isError) return
toolCalls++
if (event.toolName === 'write' || event.toolName === 'edit') {
touchedCode = true
}
})
pi.on('agent_settled', (_event, ctx) => {
if (ctx.mode !== 'tui') return
if (reflecting) {
reflecting = false
return
}
const worthIt = touchedCode && toolCalls >= MIN_TOOL_CALLS
toolCalls = 0
touchedCode = false
if (!worthIt) return
reflecting = true
if (!existsSync(SKILL_FILE)) {
mkdirSync(SKILL_DIR, { recursive: true })
writeFileSync(SKILL_FILE, TEMPLATE)
}
const full = statSync(SKILL_FILE).size > MAX_BYTES
pi.sendUserMessage(
`复盘这一轮。先读 ${SKILL_FILE},然后做两件事。\n`
+ '一、里面有没有哪条被这一轮推翻了,写错了、缺步骤、或者已经过时?有就直接改掉。\n'
+ '二、这一轮有没有一条值得新记的?标准:必须是这个项目或这套工具的特性,'
+ '不是通用编程常识;而且是你真撞上并解决了的,不是推测的。没有就回"无",不许凑。\n'
+ (full
? `文件已超 ${MAX_BYTES / 1024}KB,只许改不许加:先合并重复条目、删掉过期的。`
: '已有同类条目就改写那条,没有再追加。')
+ '\n格式:- **场景**:什么时候适用 / **做法**:具体怎么做',
{ deliverAs: 'followUp' },
)
})
}装上:
mkdir -p ~/.pi/agent/extensions
cp self-evolve.ts ~/.pi/agent/extensions//reload 生效。之后正常干活,一轮里工具调用够六次且动过代码,收尾时它会自己去读教训本,先看有没有哪条被这轮推翻了,再看有没有值得新记的。
几处细节
reflecting 这个状态位是必须的。复盘本身要调 edit 写文件,那也是一次 agent_settled;没有这个开关,它会复盘一遍复盘,无限套娃。上一篇的 auto-review.ts 用的是同一个套路,两个扩展的骨架几乎一样,换的只是 prompt 和落点。
MIN_TOOL_CALLS 的思路来自 Hermes 的 _skill_nudge_interval,但两个计数器数的不是一回事。Hermes 那个跨轮累积,复盘完才归零,默认 10(agent/agent_init.py,可以用 skills.creation_nudge_interval 改)。我这里每轮 agent_settled 都清零,数的是单轮内的调用数,所以阈值得低一些。六次是个起点,觉得吵就往上调。
几个问题
其实这套方案还有些地方要留神:
一,skill 只增不减。每轮塞一条,两周后教训本三百行。skill 按需加载,可一旦被加载就是整份进上下文,你为了省事装的东西最后在给每次请求加钱。代码里 MAX_BYTES 那个分支就是拦这个的:到顶了不许再加,只能合并。
上限该设多少,Hermes 给了个清楚的参照。它的 MEMORY.md 卡在 2200 字符、USER.md 卡在 1375(hermes_cli/config_defaults.py),tools/memory_tool.py 里硬拦,超了直接拒绝写入并回一句 “Consolidate now”。可同一个仓库里 skill 的上限是 MAX_SKILL_CONTENT_CHARS = 100_000,差了四十五倍。差别不在重要程度,在加载方式:MEMORY.md 每次会话都进系统提示词,每个字符都要在之后每次请求里付钱;skill 不用就不占。教训本是 skill,所以 12KB 不算苛刻,但也别真敢往 100KB 放。
二,垃圾进垃圾出。复盘的原料就是这一轮的过程。这一轮本身在瞎撞,复盘就会把瞎撞总结成方法论写下来,下次它还照着这份方法论继续瞎撞。内联复盘最难受的就是这个,主 agent 没有旁观者视角。prompt 里那句「必须是你真撞上并解决了的,不是推测的」是唯一的闸门,写松了整套就废了。
三,没人看。自动生成的东西最容易变成没人读的垃圾。Hermes 专门写了 curator.py 做降级归档,我们这四十行没有对应物。最省事的替代是每周扫一眼教训本,删掉不再成立的,把重复的并掉。真嫌烦就照上一篇的 launchctl 写个定时任务,每周让 Pi 自己清一遍。但清完的 diff 你得看。
所以别指望装上就不用管。它只是把「整理经验」这件事从你想起来才做,变成它每轮提醒你做一次。哪条值得留下来,还是得你自己看。
最后
上一篇讲知识该放哪一层,答案是按性质分:轻的写 skill,要保证的写 extension,复杂的交 subagent,无人值守的交系统调度器。这篇加的是一句:这几层不必全靠你手填。
四十行代码加一段 prompt 就能让它开始往回写。真正花时间的是之后每周那五分钟,看它到底写了些什么。

