数据截至 (上游 commit 6692a1b5195b)
自进化:空闲时回看对话、悄悄成长
30 秒导读: 这一章讲 CowAgent 最有个性的子系统——Self-Evolution(自进化)。它像一位在你离开后才悄悄整理笔记的助理:会话空闲一段时间后,后台自动派一个隔离的审查 agent 回看这段新对话,判断有没有值得长期学下来的东西(补一个技能、完成一件没做完的事、极少数时候补条记忆),动手改完再简短告诉你一声;绝大多数时候,它什么都不做、什么都不说。
本章属于 CowAgent 系列的一章,和它并列的还有:核心循环、系统提示词、上下文健壮性、记忆与知识、技能与工具。自进化复用了这些子系统(尤其是记忆和技能),而不是另造一套,读的时候可以随时跳回去。
1. 这是什么(零基础也能懂)
一句话定义: 自进化 = 会话空闲时,助手在后台自动回看最近的对话,发现"下次能做得更好"的地方就悄悄改掉(改技能 / 补记忆 / 完成没做完的活),大多数时候保持沉默。
它想解决什么问题。 普通聊天 agent 的"学习"发生在对话进行中——你说什么、它记什么。但很多"该长期改进"的信号,要等一整段对话结束、回头看才看得出来:比如某个技能的步骤总是漏一环,或者你让它写的文件它答应了却忘了写。自进化就是补上这个"事后复盘"的环节。
它给谁用。 面向 CowAgent 的部署者/终端用户。它现在默认开启(agent/evolution/config.py:13-15 DEFAULT_ENABLED = True,旧版本默认关闭),不想用可在 config.json 里把 self_evolution_enabled 设为 false。
它能做的四类事(按重要性排序):
| 类别 | 干什么 | 频率 |
|---|---|---|
| SKILL | 修补一个用过的技能,或把一段可复用工作流沉淀成新技能 | 主力价值,信号清晰就动手 |
| 未完成任务 | 把"答应了却没交付、且材料都齐了"的活现在补做出来 | 主力价值 |
| MEMORY | 补一条主 agent 漏掉的持久事实 | 罕见,最后手段 |
| KNOWLEDGE | 把可复用的参考知识存进 knowledge/ | 罕见 |
一句话直觉/类比: 把它想成夜里帮你整理的实习生——白天(对话中)你和主 agent 一 起干活;你走开后,它翻一遍今天新增的对话,只有确实发现该改的地方才动手,还留了草稿备份好让你随时说"撤销"。它的默认动作是"什么都不做"——这是设计上的正确结果,不是失职。
沉默是常态。 审查 agent 绝大多数时候只回一个 [SILENT](agent/evolution/prompts.py:16),整个流程静默结束,你完全无感。只有当它真的改动了工作区里的文件,你才会收到一条简短通知。
2. 顶层全景(它大概怎么转)
自进化天然分成触发侧和执行侧两半:触发侧是一个每 60 秒醒一次的后台守护线程,负责"什么时候该复盘";执行侧负责"复盘时具体怎么做"。
怎么读下面这张图: 从上到下是时间顺序。左半边(触发)在主进程里常驻;满足条件后调用右半边(执行),执行侧临时拉起一个隔离 agent 干活,干完把记录注回主会话并通知用户。
┌───────────────── 触发侧(trigger.py) ─────────────────┐
每轮用户对话 ──▶ note_user_turn:记 _evo_turns / _evo_last_active
主 agent 运行 ──▶ mark_run_active:标记 mid-run(防并发复盘)
后台守护线程(单进程,每 60s)
│ _scan_loop ─▶ _scan_once:逐个扫活跃 session
│ 判据:非 mid-run 且 idle≥idle_minutes
│ 且 enough_signal(轮数够 或 上下文压力≥80%)
▼
┌───────────────── 执行侧(executor.py) ──────────────────┐
│ run_evolution_for_session │
│ ① _build_transcript:只取"上次以来的新消息"(游标) │
│ ② create_backup:快照 MEMORY/每日/AGENT.md/非内置技能 │
│ ③ _workspace_snapshot:记录改动前的文件指纹 │
│ ④ 拉起【隔离审查 agent】(同模型 + 受限工具 + 进化提示)│
│ ⑤ 结果 == [SILENT] 或 没有文件真被改 ──▶ 静默收工 │
│ ⑥ 否则:记日志 → 注入 [EVOLUTION] 记录 → 通知用户 │
└────────────────────────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
trigger.py | 后台守护线程,判定"何时复盘" | agent/evolution/trigger.py |
executor.py | 一次复盘的完整流程与工程守卫 | agent/evolution/executor.py |
config.py | 从 config.json 读 self_evolution_* 配置 | agent/evolution/config.py |
prompts.py | 审查 agent 的系统提示 + 沉默/记录标记 | agent/evolution/prompts.py |
backup.py | 改动前快照、支持按 backup_id 回滚 | agent/evolution/backup.py |
record.py | 把每次进化追加到 memory/evolution/YYYY-MM-DD.md | agent/evolution/record.py |
一个关键设计取向:复用而非 fork。 执行侧不自己造 agent、造工具、造推送通道,而是复用主管线:AgentBridge.create_agent 造隔离 agent、ToolManager 出工具、remember_scheduled_output 注入记录、channel_factory 推送通知(agent/evolution/executor.py:16-18 的模块 docstring 明说了这一点)。§6 会拆开讲。
3. 触发侧:什么时候才该复盘
这一节讲"守护线程如何判定一个 session 该进化了"。核心在 agent/evolution/trigger.py。
3.1 一个单进程守护线程
整个进程只有一个扫描线程,由 start_evolution_trigger(trigger.py:90)启动,并用一个 _evolution_trigger_started 标志保证幂等——多次调用只会真正起一次线程:
# agent/evolution/trigger.py:90 —— 真实源码(节选)
def start_evolution_trigger(agent_bridge) -> None:
"""Start the idle-scan thread once per process (idempotent)."""
if getattr(agent_bridge, "_evolution_trigger_started", False):
return
agent_bridge._evolution_trigger_started = True
t = threading.Thread(target=_scan_loop, args=(agent_bridge,), daemon=True, ...)
t.start()
它由 AgentBridge 在初始化时调用(bridge/agent_bridge.py:392-393),是个 daemon 线程——随主进程退出,不阻塞关停。
线程主体 _scan_loop(trigger.py:103)是个死循环:睡 _SCAN_INTERVAL_SECONDS(= 60 秒,trigger.py:28)→ 读一次配置 → 若 cfg.enabled 就调 _scan_once。任何异常都被吞掉并再睡 60 秒,保证复盘线程永远不拖垮主管线(这是整个子系统反复出现的原则)。
3.2 每轮对话留下的"活动痕迹"
扫描线程要判断,靠的是主管线在每轮真实用户对话后往 agent 实例上记的几个轻量属性。这activity由 note_user_turn(trigger.py:56)写:
| 属性 | 含义 |
|---|---|
_evo_last_active | 最后一轮对话的 epoch 秒 |
_evo_turns | 距上次进化以来的用户轮数 |
_evo_channel_type | 来源渠道(供之后通知) |
_evo_receiver | 推送目标 |
注意计数是按"用户轮"而非按"消息"(见 trigger.py 顶部 docstring 第 11 行),而且每次复盘在执行侧拿到并发名额后就把基线 _evo_turns 清零(executor.py:416, 清零动作已从 trigger 移进 executor),这样一段长对话可以多次进化、且不会重复评判旧内容。
note_user_turn 从主管线两处被调用:bridge/agent_bridge.py:835-841 和 agent/chat/service.py:436-441。群聊时 receiver 会传空(不向群里推送私人进化结果)。
3.3 防并发:别在 agent 正干活时去复盘
如果一次用户请求本身跑得比 idle_minutes 还久,扫描线程可能在 agent 正在生成回答时就去复盘同一个 session——那会读到半截 transcript、甚至和主运行抢工作区。mark_run_active(trigger.py:76)就是防这个:
# agent/evolution/trigger.py:76 —— 真实源码(节选)
def mark_run_active(agent, active: bool) -> None:
try:
agent._evo_run_active = bool(active)
if active:
agent._evo_last_active = time.time() # 运行中也算"活跃",顺延空闲计时
主管线在开始一次运行前置 True、结束后置 False(agent/chat/service.py:60,213),扫描时只要看到 _evo_run_active 为真就直接跳过(trigger.py:130)。
3.4 触发判据:idle + enough_signal
_scan_once(trigger.py:116)遍历所有活跃 session,对每个逐条过滤。判据是三个条件同时成立:
非 mid-run (_evo_run_active 为假) trigger.py:130
且
enough_signal (轮数够 OR 上下文压力够) trigger.py:135
且
idle 足够 (now - _evo_last_active ≥ idle_seconds) trigger.py:138-139
其中最有意思的是 enough_signal 的"或"逻辑(trigger.py:135):
# agent/evolution/trigger.py:135 —— 真实源码
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
- 路径 A:轮数够。
_evo_turns达到cfg.min_turns(默认 6,config.py:17)。 - 路径 B:上下文压力够。 即使轮数不够,只要上下文已经涨过预算的 80%,也触发。
为什么要路径 B? 因为一段"轮数少但每轮都很长"的对话,眼看就要被上下文裁剪(见 上下文健壮性)挤掉旧内容——那些内容一旦被裁就再也复盘不到了。所以在"上下文压力"下提前抢救一次,思路和 OpenClacky / Claude Code 在上下文压力下做整合一致(trigger.py:5-8 docstring)。
_context_pressure_reached(trigger.py:38)复用 agent 自己的 token 估算,和现有裁剪路径口径一致,超预算 80%(_CONTEXT_RATIO = 0.8,trigger.py:34)即为真;任何异常一律返回 False(best-effort,宁可不触发)。
3.5 拿到并发名额才清零
命中后有个容易被忽略但重要的细节——基线清零发生在执行侧拿到并发名额之后,而不是在 trigger 里:
# agent/evolution/executor.py:412-416 —— 真实源码(节选)
# Consume the trigger only after this pass has secured a concurrency
# slot and resolved its session. Sessions rejected by the gate keep
# their accumulated turns and remain eligible for the next scan.
agent._evo_turns = 0
先清零,是为了让复盘期间新到的对话轮从头累计,不会因为一次慢复盘而被重复触发;而被并发闸门挡下的 session 则保留累计轮数,下轮扫描仍有机会。
4. 执行侧:一次复盘具体怎么做
这一节走一遍 run_evolution_for_session(agent/evolution/executor.py:374)的完整流程。它可以从后台线程安全调用,所有失败都被吞并记日志——进化绝不能干扰主管线。
4.1 只看"上次以来的新消息"(游标)
复盘不该每次都把整段历史重判一遍(既贵又会重复写记 忆)。执行侧在 agent 实例上维护一个内存游标 _evo_done_msg_count,只取游标之后的新消息:
# agent/evolution/executor.py:426-430 —— 真实源码(节选)
done = int(getattr(agent, "_evo_done_msg_count", 0))
if done > total_msgs:
done = 0 # history was trimmed/reset; start fresh
new_messages = all_messages[done:]
transcript = _build_transcript(new_messages)
这个游标存在内存、重启即丢(executor.py:423-425 有明确注释):代价至多是重启后多做一次冗余复盘,而下游的"文件是否真变"检查会兜住它,不会重复写入相同记忆。
_build_transcript(executor.py:89)把新消息渲染成紧凑文本;若超过 12000 字符,保留最近的尾部(尾部最相关,executor.py:103-105)。若 transcript 为空,就悄悄把游标推到末尾并返回,不打日志(每分钟的扫描会碰到每个空闲 session,这类 no-op 打日志纯是噪音,executor.py:431-435)。
4.2 改动前的两层快照(为"撤销"和"anti-nag"服务)
在动手前,执行侧做两种快照,目的不同:
第一层——内容快照(为撤销)。 create_backup(backup.py:29)把这些文件复制进 memory/.evolution_backups/<backup_id>/,并写一份 manifest.json:
MEMORY.md(长期记忆索引)与今天的每日文件memory/YYYY-MM-DD.mdAGENT.md(人设,极少改,但也纳入以便撤销,executor.py:475)- 每一个非内置技能的
SKILL.md(executor.py:477-487)
内置技能被刻意排除在备份之外——因为它们根本不该被改(见 §5.2),没必要备份。backup.py 还会用 _prune_old_backups 只保留最近 10 份(backup.py:92-100),控制磁盘占用。配套的 restore_backup(backup.py:67)按 manifest 逐个还原,支撑"撤销"。
第二层——指纹快照(为 anti-nag)。 _workspace_snapshot(executor.py:310)扫一遍受关注的子树,记录每个文件的 (mtime, size),不读内容,很便宜:
# agent/evolution/executor.py:301 —— 真实源码
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output")
它还特意排除进化自己的记账目录和夜间 Deep Dream 日记(_MEMORY_IGNORE,executor.py:304)、以及技能子系统自动维护的开关索引 skills_config.json(executor.py:307)——这些文件即使变了也不算"用户可见的改动信号"。这层快照是 §4.5"确实改了才通知"的判据。