my-pi-agent--context管理
功能设计
架构全景
1 | ┌─────────────────────────────────────────────────────────────────┐ |
四层压缩
1 | prepare(messages) → 发送视图 |
一次 run 的完整数据流
1 | Agent.run("问题") |
ContextManager
1 | ContextManager |
| 内部方法 | 功能 | 关键点 |
|---|---|---|
_prepare_with_cache |
缓存视图拼装:[system] + 摘要 + retained_tail 快照 + messages[covered+len(tail):] |
“之后新增”的定位(covered_count 决定) |
_do_summarize |
压缩执行:定 cut → 摘要调用 → 写缓存 →
pending_compaction |
降级返回原视图;persona 保留;摘要 user 角色 |
_find_cut |
切点定位:从尾累积字符达 keep_recent → 对齐 user
边界 |
不拆 tool 配对(协议不变式) |
_call_summarizer |
摘要调用:self.llm.chat([system, 模板], tools=[]) |
复用 Agent LLM;迭代附旧摘要 |
_extract_summary |
摘要清洗:剥离 <analysis> 只留
<summary>;无标签容错 |
防止草稿污染压缩结果 |
核心方法 prepare —— 四层管线
完整决策树
1 | prepare(messages) |
核心代码
1 | def prepare(self, messages: list[Message]) -> list[Message]: |
四层管线拆解
L3 budget:大工具结果落盘
1 | def budget_tool_results(messages, max_chars=20000, results_dir=None): |
设计: - 针对最大头:单条 tool 结果 >20000 字符(≈5000 token)→
完整内容写进 .my_agent_core/tool-results/
L1 snip:裁中间消息
1 | def snip_messages(messages, max_messages=50): |
设计: - 针对消息条数:>50 条 → 留头 3(初始上下文)+ 尾 46(当前工作),中间删,插一条 [snipped N messages] 占位 - 配对不变式(关键):切口绝不落在 assistant(tool_calls) + tool 中间——头边界如果有 assistant(tool_calls) 就把后续 tool 并入;尾边界如果从 tool 开始且前一条是 assistant(tool_calls) 就往前并 - 占位符计入预算:keep_tail = max-4(3 头 + 1 占位 + 46 尾 = 50)——这是 Task 2 修过的 off-by-one
L2 micro:旧工具结果占位
1 | def micro_compact(messages, keep_recent=5, min_chars=200): |
设计: - 针对旧工具结果:非最近 5 条、>200 字符的 tool 消息 → content 换一行占位符 - metadata 保留(model_copy 只改 content)——tool_call_id 还在,配对不变式保住(协议要求 tool 消息要跟 assistant(tool_calls) 配对,占位后配对关系不变) - 最近 5 条不动:对话正在用的结果保留
L4 摘要:LLM 语义压缩(最后才用,唯一的 API 成本)
1 | def _do_summarize(self, messages): |
设计: - 保留尾部:keep_recent_tokens 预算(默认 budget//4)→ 最近消息完整保留(对话正在进行) - 摘要旧段:前面的历史由 LLM 压成结构化摘要(## Goal 等) - persona 保留:原 system 单独放视图头(不折叠进摘要) - 降级:摘要失败/空 → 返回原视图(从没改过树,零回滚)
增量更新:不是每次从零开始
如果一个长对话被压缩了多次(第一次压缩第 1-30 轮,第二次压缩第 31-50
轮),第二次压缩时会传入上一次的摘要作为
previousSummary:
1 | 第一次压缩: |
这让 LLM 做的是更新而非重写——已有的 Goal/Constraints 保留,新增的 Progress 追加。比每次从零开始写摘要更稳定。
1. 提示词模板中预留了 previous_summary 插槽:
1
2
3
4
5
6SUMMARIZATION_PROMPT_TEMPLATE = (
"Summarize this conversation so work can continue without losing essential state.\n"
"Preserve: 1. Current goal, 2. User constraints & preferences, ...\n\n"
"Previous summary:\n{previous_summary}\n\n" # 👈 核心:将上一轮摘要喂给模型
"Conversation:\n{conversation}"
)
### 2. 调用摘要器时自动附带上一轮摘要(self._summary):
1
2
3
4
5
6
7async def _call_summarizer(self, messages: list[Message]) -> tuple[str, ...]:
conversation = _serialize_messages(messages)
user_content = SUMMARIZATION_PROMPT_TEMPLATE.format(
previous_summary=self._summary or "(none)", # 👈 存在旧摘要就自动注入,首次则为 (none)
conversation=conversation,
)
...
_prepare_with_cache:prepare 的缓存分支
它做三件事
① 找到”新增”: 压缩后新聊的消息(covered + 尾部快照 之后的部分) ② 处理新增: 新消息也可能有大工具结果/堆旧结果 → 免费压一压(L3/L2) ③ 拼成视图: [人设] + [摘要] + [尾部快照] + [新增]
它在整个机制里的角色
压缩前(无缓存): prepare → 免费层 → 超阈 → 摘要(花 1 次 API)→ 存下 (摘要, 覆盖数, 尾部快照)
压缩后(有缓存): ← 从这里开始,每轮都走它 prepare → _prepare_with_cache → 拼视图 → 发出去(0 次 API) ↑ 这就是”压缩成果怎么被反复使用”的机制
每次发: [原 system 人设] + [摘要(代替旧 18 轮)] + [压缩时保留的尾部 2 轮] + [压缩后新增的所有轮次]
- 旧 18 轮 → 用摘要代替(压缩的成果,不重发)
- 新增轮次 → 每次都带上(对话在继续,新内容必须给模型)
- 尾部 2 轮 → 从快照拿(压缩时存的,不用重算)
token估算机制:chars/4 启发式 + usage 锚定
1 | 估算 = 字符数 × ratio |
Token 计算 = “字符数 × 比例”:字符数来自完整消息序列化(含 metadata);第一轮 chars/4 兜底;之后 ratio = 上轮实测 prompt_tokens / 上轮视图字符数(usage 锚定,每轮更新)
第一步:字符数怎么算
1 | def estimate_tokens(messages, ratio=None): |
它序列化整个消息列表(json.dumps),算出的字符数包括:
1 | [{"role":"user","content":"37*19=?","metadata":null}, |
- 每条消息的 role + content + metadata(tool_calls 的完整 JSON)
- metadata 也计入——tool_calls 是 JSON 结构,占字符
- ensure_ascii=False:中文不转义(会膨胀字符数,转义后失真)
- overhead:[, {, “role”:, 逗号等每条约 50 字符
第二步:chars/4 兜底(第一轮)
return max(1, chars // CHARS_PER_TOKEN) # CHARS_PER_TOKEN = 4
为什么是 4:英文平均 1 token ≈ 4 字符(OpenAI 的估算惯例)。中文 1 token ≈ 1-2 字符(会低估),但这是”兜底”——第一轮没数据,只能按通用惯例猜。
第三步:usage 锚定(关键校准)
1 | # 每轮 llm.chat 后,Agent 喂 usage: |
关键点: - 锚的是 prompt_tokens(输入侧实测)——我们估的就是”发给模型多少” - _last_view_chars:上次 prepare 返回视图的字符数(_chars_of(view))——和 usage 对应的是”上次真正发的那个视图” - ratio = 实测 token / 实际字符——校准”每字符 ≈ 多少 token”,贴合当前模型的语言分布(中文多点 ratio 大点、代码多点 ratio 大点)
_find_cut:尾部保留机制
_find_cut 回答一个关键问题:“L4 摘要时,把哪条消息之前的历史压掉、保留哪条之后的尾部?”
对话 30 条,估算超阈,要摘要了。但不能全压——最近的消息(模型正在处理的)要保留。问题是:压到哪一条为止?
1 | messages: [system, m1, m2, ..., m25, m26, m27, m28, m29, m30] |
_find_cut 就是找”分界线”在哪——返回 cut 索引,messages[:cut] 被摘要、messages[cut:] 保留。
逐段拆解
尾部预算 → 字符预算
1 | budget_chars = self.keep_recent_tokens * 4 |
keep_recent_tokens(默认 budget//4)是”尾部保留多少 token”——转成字符(×4,启发式反推)。这是”保留多少”的设定:尾部要够模型继续干活,但不能太多(否则压了等于没压)。
从尾向前累积字符
1 | acc = 0 |
从尾部倒数,累积字符,直到达到尾部预算——这条消息就是”尾部起点”:
从 m30 往前数: m30(100字) + m29(150字) + … 累积到 ≥ 400 字符(budget_chars) → cut = 那条消息的 index
跳过 index 0(system)——system 永远不参与切(人设)。
对齐到 user 边界(关键配对保护)
1 | while cut > 1 and messages[cut - 1].role != "user": |
对齐:cut 处不是 user → 往前移,直到 cut-1 是 user → cut 移回 m18(user 之后)→ messages[:18] 摘要、messages[18:] 尾部 → assistant(tool_calls)+tool 配对不会在切口被拆
为什么必须对齐 user:摘要切点如果拆开 assistant(tool_calls) + tool 配对,模型看到的对话协议就坏了(tool_calls 没有对应结果)。对齐到 user 边界 = 配对永远完整。
核心:缓存 entry 机制
缓存 entry = 压缩成果的持久化档案袋:content(摘要)代替旧段、retained_tail(尾部快照)提供尾部、covered_count 定位新增——三件套写进 session 树随文件落盘;进程重启经 get_latest_compaction_cache(最深=最新)读回、restore_cache 填回内存,每轮 _prepare_with_cache 拼视图免重算;rewind 护栏保证缓存永不失效;真相(完整历史)始终在树里,缓存只是提示。
缓存 entry 是什么:压缩成果的”档案袋”
压缩后,把”这次压缩的成果”写进 session 树,作为一条 type=“compaction” 的 entry:
1 | {"id":"s1","parent_id":"e20","type":"compaction","role":"system", |
它回答:“这次压缩把哪些历史压成了什么、保留了哪些尾部”——之后发消息不用重新压,直接用它拼视图。
retained_tail 是什么:尾部快照
1 | # 压缩时(_summarize_from_cut): |
压缩时:messages = [旧段 22 条] | [尾部 8 条] ↑cut ↑retained → 存成 retained_tail 快照
为什么用快照而不是存引用: - 快照是独立的复制(dict),不依赖”当前消息列表的状态” - rewind 护栏保证快照永远有效(不能 rewind 回覆盖区改变它) - 拼视图时 Message(**d) 从快照还原——旧尾部的消息即使在树里被后续操作影响,快照还是原样
多级压缩:树里多条缓存 entry
第二次压缩后: s1(第一次压缩) 覆盖 0-18,tail e19-20 s2(第二次压缩) 覆盖 0-27,tail e28-30 ← 最新
树里两条 type=“compaction”: get_latest_compaction_cache → 最深(s2)→ 只用 s2 旧的 s1 留在树里当历史痕迹(get_full_history_messages 过滤掉,不影响历史)
“最新”由 compaction_floor 单调前移保证——最新压缩的 entry 永远最深,max 选择永远正确。
rewind的限制(compaction_floor 护栏)
压缩把旧段变成了摘要。如果压缩后还能 rewind 回旧段重新对话,会出问题:
压缩后:旧段 e1..e18 → 摘要 s1,尾部 e19..e20 保留 用户 rewind 回 e5(旧段里): → 对话从 e5 重新开始 → 新消息挂在 e5 下 → 但 e6..e18 既没被摘要覆盖(摘要只覆盖到 e18 那一段的视角), 也不在新路径上(rewind 到 e5 甩掉了 e6..e18) → “既没摘要也没保留”的真空 ✗ → 而且摘要缓存(covered=18)和当前路径对不上 → 缓存失效
所以护栏:压缩后不允许 rewind 回压缩点之前——旧段封存,只能从压缩点之后继续。
增加缓存entry后,树的结构
关键设计:缓存 entry 直接插入 entries dict,不动 current_id——所以它是”旁挂”的,不在当前路径上:
1 | r1 |
| 读取 | 结果 |
|---|---|
get_current_path() |
主干 r1→…→e30(不含缓存 entry——它们旁挂) |
get_full_history_messages() |
过滤 type==message → 纯历史(不含缓存) |
get_latest_compaction_cache() |
专门找最深 compaction → 缓存数据 |
rewind |
护栏:只能回 floor 之后 |
如何获取完整历史记录
session 树: e1..e22(完整历史) ← 真相,永远在 s1(缓存 entry) ← 提示,只是”拼视图时用它代替旧段” e23..e30(保留尾部) ← 真相,也在
两条读取路径: get_full_history_messages() → 过滤 type=compaction → 纯历史(真相) get_latest_compaction_cache() → 专门读缓存 entry → 恢复 ctx
缓存 entry 不是真相——它是”压缩成果的档案”,真相永远是完整历史。它只是让”发消息时用摘要代替旧段”这件事免重算。
ContextSessionBridge
职责边界
✓ 桥做:ContextManager ↔︎ Session 的转换(缓存读写、L3 目录推导) ✗ 桥不做: - 不算视图(ContextManager 的活) - 不存真相(Session 的活) - 不发事件(Agent 的 _emit 私有) - 不碰 hook/registry/循环(Agent 协调)
1 | ContextManager(纯视图) Session(真相 + 持久化) |
设计原则:ContextManager 保持纯净(只管”消息 → 视图”),Session 只管真相。但压缩成果要在两者之间传递(写缓存 entry、读缓存恢复)——桥就是干这个的,让 ContextManager 不用 import session、Agent 不用自己写交互逻辑。
restore_cache(ctx) —— 读缓存(恢复)
1 | cache = self.session.get_latest_compaction_cache() # Session 找最新缓存 entry |
回答:“进程重启后,怎么让 ctx 免重算?”
session 树里 type=compaction entry → get_latest_compaction_cache() (最深 = 最新) → {summary, covered_count, retained_tail} → ctx.restore_cache(…) (填进 self._summary 等)
调用时机:Agent 构造时(init 里 bridge.restore_cache(ctx))——之后每轮 prepare 用缓存拼视图。
write_compaction(ctx) —— 写缓存(压缩后)
1 | info = ctx.pending_compaction |
回答:“压缩完成后,成果怎么落盘?”
ctx.pending_compaction(CompactionInfo) → session.add_summary_cache(summary, covered_count, retained_tail, …) → 写 type=compaction entry + compaction_floor + save()
调用时机:Agent 的 _handle_compaction 里(每次 prepare 后检查)——有压缩就写。
数据流全景(一次完整的压缩 → 恢复)
1 | 压缩发生: |
Agent 只调用桥的 3 个方法——不碰桥内部(不直接读 session 树、不直接写缓存 entry)。
1 | # Agent.__init__: |
session部分配合改动
会话实体与持久化演进:强类型 CompactionEntry
在阶段 18 模块化重塑后,压缩成果由现代的强类型
CompactionEntry(session/entries.py)正式表达,彻底摒弃了早期将
system 消息旁挂在 entries 树上的临时做法:
1 | class CompactionEntry(BaseSessionEntry): |
职责:清晰记录“哪一部分历史条目被本次压缩合并为了单条摘要”,以纯追加形式写入 JSONL。
Session 状态:compaction_floor 护栏锚点
1 | self.compaction_floor: str | None = None # 压缩时刻 current id |
职责:rewind
护栏的绝对锚点——记录“最后一次压缩时对话推进到了哪里”。为了防止时空穿越导致缓存失效,之后的分支回溯只允许回到
compaction_floor 之后长出的新节点。
纯函数状态折叠:SessionState
与 _apply_compaction (memory.py)
当 Agent 需要从历史条目计算当前视图时,memory.py
的折叠引擎会自动处理压缩替换:
1 | def _apply_compaction(messages: list[Message], entry: CompactionEntry) -> list[Message]: |
关键设计红利: - 存算分离:磁盘物理文件只管忠实记录
CompactionEntry,不篡改历史行; -
纯函数折叠:模型加载时通过纯函数
from_entries
自动将历史条目折叠为干净的带摘要视图,既保证了物理磁盘的
Append-Only,又保证了逻辑视角的优雅紧凑。
get_full_history_messages:过滤缓存节点
1 | def get_full_history_messages(self): |
职责:宿主看历史、Agent 恢复上下文都用它——剔除缓存 entry,返回纯历史(真相)。现有 get_current_path_messages 保留原语义,但 Agent 不再用它恢复。
CompactionInfo
1 | class CompactionInfo: |
它是数据搬运工——ContextManager 完成压缩后,把”这次压缩的全部分发数据”打包成一个对象挂在 pending_compaction 上;Agent 拿到后不用知道 ContextManager 内部细节,只从这个盒子取数据就行(发事件、存 session)。
1 | ContextManager 压完 → 把成果装进一个信封(CompactionInfo) |
压缩 Prompt 体系与 6 Section 约束
在进行 L4 大模型结构化摘要时,Prompt 的质量直接决定了压缩后记忆的保真度与防注入安全性。
1.
系统提示词:SUMMARIZATION_SYSTEM_PROMPT(防注入隔离)
1 | SUMMARIZATION_SYSTEM_PROMPT = ( |
2.
用户提示词模板:SUMMARIZATION_PROMPT_TEMPLATE(6 Section
强制填表)
我们抛弃了自由文本总结,强制大模型按照 6 大核心维度输出标准
Markdown,并预留了 previous_summary 进行增量演进:
1 | SUMMARIZATION_PROMPT_TEMPLATE = ( |
3.
文件足迹自动提取与累积(<read-files> /
<modified-files>)
每次生成摘要时,extract_file_operations
自动从被压缩历史中扫描
read/edit/write
工具调用,并继承旧摘要中的文件记录,格式化附加在摘要末尾:
1 | ## Goal |
4.
摘要清洗提取:_extract_summary(content)
1 | def _extract_summary(content: str) -> str: |
# 上下文重建的全景架构图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31磁盘 session.jsonl / self.messages (完整无损历史)
│
▼
ContextManager.prepare(messages)
│
【检查内存中是否有摘要缓存?】
/ \
[ 有缓存 ] [ 无缓存 ]
/ \
▼ ▼
_prepare_with_cache() 依次通过免费层:
┌─────────────────────────┐ 1. L3 大结果落盘 (换预览)
│ 1. 原始 System 提示词 │ 2. L1 中间轮次裁切
│ 2. [Context summary...] │ 3. L2 旧工具结果折叠
│ (6 Section + 文件足迹)│ └────────────┬────────────┘
│ 3. retained_tail 快照 │ │
│ 4. 压缩后产生的新增消息 │ ▼
└────────────┬────────────┘ 【估算 Token 是否超 80% 预算?】
│ / \
▼ [ 否 ] [ 是 ]
【估算 Token 是否超 80% 预算?】 / \
/ \ 直接返回 view 触发 L4 摘要
[ 否 ] [ 是 ] (0 API 损耗⚡) (_do_summarize)
/ \ │
直接返回 view 迭代再摘要 ▼
(0 API 损耗⚡) (传入旧摘要增量演进) 生成新摘要并写缓存
│ │
└──────────────┬─────────────┘
│
▼
发给大模型的最终临时视图 (view)