@eoasmxd/freya 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/core/package.json +2 -2
- package/doc/_index.md +20 -7
- package/doc/getting-started.md +1 -1
- package/doc/installation-guide.md +2 -2
- package/doc/{architecture-design.md → specifications/architecture-design.md} +7 -4
- package/doc/{config-spec.md → specifications/config-spec.md} +1 -1
- package/doc/{llm-interface-params.md → specifications/llm-interface-params.md} +8 -8
- package/doc/{prompt-system.md → specifications/prompt-system.md} +1 -1
- package/doc/tutorials/_index.md +96 -0
- package/doc/tutorials/part0_basic/0.1_probability_prediction.md +155 -0
- package/doc/tutorials/part0_basic/0.2_attention_and_context.md +145 -0
- package/doc/tutorials/part0_basic/0.3_generation_parameters.md +132 -0
- package/doc/tutorials/part0_basic/0.4_debugging_token.md +99 -0
- package/doc/tutorials/part0_basic/1.1_stateless_and_history.md +143 -0
- package/doc/tutorials/part0_basic/1.2_chat_data_structure.md +179 -0
- package/doc/tutorials/part0_basic/1.3_system_user_assistant.md +147 -0
- package/doc/tutorials/part0_basic/1.4_freya_model_proxy.md +203 -0
- package/doc/tutorials/part0_basic/_index.md +28 -0
- package/doc/tutorials/part1_react/2.1_agency_vs_chatbot.md +127 -0
- package/doc/tutorials/part1_react/2.2_react_mind_model.md +144 -0
- package/doc/tutorials/part1_react/2.3_freya_agent_executor.md +233 -0
- package/doc/tutorials/part1_react/2.4_debugging_loop_deadlock.md +160 -0
- package/doc/tutorials/part1_react/3.1_hardcoded_prompt_pain.md +104 -0
- package/doc/tutorials/part1_react/3.2_decoupled_architecture.md +138 -0
- package/doc/tutorials/part1_react/3.3_freya_dual_read_probe.md +152 -0
- package/doc/tutorials/part1_react/3.4_debugging_composition_placeholder.md +97 -0
- package/doc/tutorials/part1_react/_index.md +28 -0
- package/doc/tutorials/part2_tools/4.1_json_schema_mapping.md +119 -0
- package/doc/tutorials/part2_tools/4.2_tool_call_raw_packet.md +105 -0
- package/doc/tutorials/part2_tools/4.3_freya_tool_execution.md +145 -0
- package/doc/tutorials/part2_tools/4.4_debugging_observation_fix.md +154 -0
- package/doc/tutorials/part2_tools/5.1_observation_injection.md +131 -0
- package/doc/tutorials/part2_tools/5.2_openai_vs_gemini_protocol.md +125 -0
- package/doc/tutorials/part2_tools/5.3_freya_llm_proxy_mapping.md +162 -0
- package/doc/tutorials/part2_tools/5.4_debugging_parallel_call_chaos.md +135 -0
- package/doc/tutorials/part2_tools/_index.md +28 -0
- package/doc/tutorials/part3_memory/6.1_session_state_lifecycle.md +143 -0
- package/doc/tutorials/part3_memory/6.2_physical_sandbox_separation.md +108 -0
- package/doc/tutorials/part3_memory/6.3_freya_session_storage.md +139 -0
- package/doc/tutorials/part3_memory/6.4_debugging_session_concurrency.md +161 -0
- package/doc/tutorials/part3_memory/7.1_context_overflow_loss.md +109 -0
- package/doc/tutorials/part3_memory/7.2_sliding_window_vs_summary.md +85 -0
- package/doc/tutorials/part3_memory/7.3_freya_compactor_impl.md +142 -0
- package/doc/tutorials/part3_memory/7.4_debugging_summarize_deadlock.md +160 -0
- package/doc/tutorials/part3_memory/_index.md +28 -0
- package/doc/tutorials/part4_streaming/8.1_sse_protocol_basics.md +119 -0
- package/doc/tutorials/part4_streaming/8.2_hiding_thoughts_in_stream.md +132 -0
- package/doc/tutorials/part4_streaming/8.3_freya_event_bus.md +104 -0
- package/doc/tutorials/part4_streaming/8.4_debugging_stream_decoder.md +158 -0
- package/doc/tutorials/part4_streaming/9.1_abort_signal_braking.md +162 -0
- package/doc/tutorials/part4_streaming/9.2_async_event_channels.md +142 -0
- package/doc/tutorials/part4_streaming/9.3_freya_abort_billing.md +122 -0
- package/doc/tutorials/part4_streaming/9.4_debugging_abort_lock_deadlock.md +187 -0
- package/doc/tutorials/part4_streaming/_index.md +28 -0
- package/doc/tutorials/part5_plugins/10.1_microkernel_decoupling.md +142 -0
- package/doc/tutorials/part5_plugins/10.2_plugin_metadata_security.md +125 -0
- package/doc/tutorials/part5_plugins/10.3_channel_plugin_development.md +149 -0
- package/doc/tutorials/part5_plugins/10.4_debugging_channel_reconnection.md +160 -0
- package/doc/tutorials/part5_plugins/_index.md +21 -0
- package/doc/tutorials/part6_advanced/11.1_react_model_flaws.md +111 -0
- package/doc/tutorials/part6_advanced/11.2_reflexion_mind_model.md +103 -0
- package/doc/tutorials/part6_advanced/11.3_reflexion_hands_on.md +182 -0
- package/doc/tutorials/part6_advanced/11.4_debugging_reflexion_convergence.md +108 -0
- package/doc/tutorials/part6_advanced/12.1_single_agent_limits.md +100 -0
- package/doc/tutorials/part6_advanced/12.2_multi_agent_patterns.md +121 -0
- package/doc/tutorials/part6_advanced/12.3_freya_multi_agent_routing.md +143 -0
- package/doc/tutorials/part6_advanced/12.4_multi_agent_hands_on.md +176 -0
- package/doc/tutorials/part6_advanced/_index.md +28 -0
- package/doc/tutorials/preface.md +30 -0
- package/package.json +2 -2
- package/plugins/plugin-gemini/package.json +1 -1
- package/plugins/plugin-openai/package.json +1 -1
- package/plugins/plugin-telegram-channel/package.json +1 -1
- package/plugins/plugin-tool-fs/package.json +1 -1
- package/plugins/plugin-tool-memory/package.json +1 -1
- package/plugins/plugin-tool-web/package.json +1 -1
- package/plugins/plugin-wecom-channel/package.json +1 -1
- package/plugins/plugin-weixin-channel/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "6.4 调试与避坑指南:高并发会话串线与写入竞态排查"
|
|
3
|
+
weight: 40
|
|
4
|
+
description: "实战调试并发异步调用下的会话状态污染隐患,剖析显式传参无状态隔离与单链异步写排队锁的物理防御机制。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 6.4 调试与避坑指南:高并发会话串线与写入竞态排查
|
|
8
|
+
|
|
9
|
+
在前几节中,我们建立了会话的生命周期管理与数据源码的物理隔离,并剖析了 `SessionManager` 的双层索引与懒加载设计。
|
|
10
|
+
|
|
11
|
+
然而,当我们的智能体服务面临多会话并发调用时,有一个极其经典的异步编程幽灵 —— **Session 状态污染(串线)与写入竞态**:
|
|
12
|
+
* 会话 A 正在处理复杂的推理任务。
|
|
13
|
+
* 同一微秒,会话 B 发送了新的请求。
|
|
14
|
+
* 如果底座在处理并发异步请求时,错误地依赖了全局/模块级共享状态,**会话 A 的上下文就可能被误赋值给会话 B,导致数据串线!**
|
|
15
|
+
* 如果两个并发操作同时向同一个会话追加数据,还会因为文件读写竞态导致后写入的内容覆盖先写入的内容。
|
|
16
|
+
|
|
17
|
+
本节我们将通过本地动手实验复现这一串线大坑,并白盒解剖 Freya 是如何依靠“显式传参无状态化”与“单链异步排队锁”在现有代码中建立起严密防御的。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 一、 Node.js 异步模型下的“状态污染”成因
|
|
22
|
+
|
|
23
|
+
Node.js 是单线程、非阻塞 I/O 的。在处理并发异步请求时,Node.js 依靠事件循环在不同的 `await` 异步回调之间频繁进行执行权转移。
|
|
24
|
+
|
|
25
|
+
如果在底座中,写出了如下包含“全局/模块级单例变量”的代码,灾难就会发生:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// ❌ 存在致命并发串线隐患的模块级单例变量
|
|
29
|
+
let currentSessionId: string;
|
|
30
|
+
|
|
31
|
+
export class UnsafeAgentService {
|
|
32
|
+
async handleRequest(req: any) {
|
|
33
|
+
// 1. 设置当前会话 ID
|
|
34
|
+
currentSessionId = req.sessionId;
|
|
35
|
+
|
|
36
|
+
// 2. 💡 异步 await:控制权在这里发生转移!Node.js 暂停当前流程,去执行其他并发请求
|
|
37
|
+
const history = await database.loadHistory(currentSessionId);
|
|
38
|
+
|
|
39
|
+
// 3. 此时 currentSessionId 可能已被后入的并发请求篡改为了 "Session_B"!
|
|
40
|
+
const response = await llm.chat(history);
|
|
41
|
+
|
|
42
|
+
// 4. 会话 A 拿到了错误的会话上下文
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 二、 本地调试:复现并发“串线”
|
|
51
|
+
|
|
52
|
+
为了直观观察这一事故,我们在本地编写一个极简的异步并发测试脚本:
|
|
53
|
+
|
|
54
|
+
### 1. 串线复现脚本 (pollution_test.js)
|
|
55
|
+
```javascript
|
|
56
|
+
// 全局共享的单例变量 (污染源)
|
|
57
|
+
let activeSessionId = null;
|
|
58
|
+
|
|
59
|
+
async function unsafeChatFlow(sessionName, targetSessionId, delayMs) {
|
|
60
|
+
// 1. 模拟设置当前活跃 Session
|
|
61
|
+
activeSessionId = targetSessionId;
|
|
62
|
+
console.log(`[请求启动] 任务 ${sessionName} 启动,设置当前 Session 为: ${activeSessionId}`);
|
|
63
|
+
|
|
64
|
+
// 2. 模拟读取数据库或调用大模型的异步延迟 (控制权转移间隙)
|
|
65
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
66
|
+
|
|
67
|
+
// 3. 💡 物理读取:此时 activeSessionId 已经被并发请求覆盖!
|
|
68
|
+
console.log(`[读取阶段] 任务 ${sessionName} 在 async 回调中读取到的 Session 是: ${activeSessionId}`);
|
|
69
|
+
|
|
70
|
+
if (activeSessionId !== targetSessionId) {
|
|
71
|
+
console.error(`🚨 [串线灾难] 发现严重数据串线!任务 ${sessionName} 的数据被篡改为了: ${activeSessionId}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runPollutionTest() {
|
|
76
|
+
console.log("🚀 启动并发串线模拟测试...");
|
|
77
|
+
|
|
78
|
+
// 并发请求 1:任务 A 先入,延迟较长 (模拟复杂推理)
|
|
79
|
+
const reqA = unsafeChatFlow("A", "session_alpha", 100);
|
|
80
|
+
|
|
81
|
+
// 并发请求 2:延迟 10ms 后,任务 B 连入 (模拟并发流)
|
|
82
|
+
await new Promise(r => setTimeout(r, 10));
|
|
83
|
+
const reqB = unsafeChatFlow("B", "session_beta", 20);
|
|
84
|
+
|
|
85
|
+
await Promise.all([reqA, reqB]);
|
|
86
|
+
}
|
|
87
|
+
runPollutionTest();
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 2. 测试输出分析
|
|
91
|
+
运行上述脚本,你会看到:
|
|
92
|
+
1. 任务 A 启动,全局变量被设为 `session_alpha`。
|
|
93
|
+
2. 10 毫秒后,任务 B 连入,全局变量被强行覆写为 `session_beta`。
|
|
94
|
+
3. 任务 B 执行结束,读取到自己的 `session_beta`。
|
|
95
|
+
4. 任务 A 推理结束,开始读取,惊悚地发现**全局变量已经变成了 `session_beta`!**
|
|
96
|
+
5. **警报响起**:任务 A 读取到了任务 B 的会话 ID。串线现场确证。
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 三、 Freya 的第一道防线:显式调用链透传与无状态化
|
|
101
|
+
|
|
102
|
+
要彻底消灭这类异步串线,Freya 源码遵循了严格的**“无状态化与显式上下文传参”**设计原则:
|
|
103
|
+
|
|
104
|
+
1. **绝对禁止模块级会话状态**:任何文件顶部绝对不允许声明诸如 `let currentSessionId` 的共享可变变量。
|
|
105
|
+
2. **显式栈帧传参**:在执行器与会话管理器中,所有方法均通过函数参数显式传递 `sessionId`。
|
|
106
|
+
- `FreyaAgentExecutor.run(sessionId, userMessage, ...)`
|
|
107
|
+
- `FreyaSessionManager.appendMessages(sessionId, messages, ...)`
|
|
108
|
+
- 工具执行时注入私有会话:`args.__sessionId = sessionId;`
|
|
109
|
+
|
|
110
|
+
由于每个异步函数调用都拥有自己独立的 JavaScript 栈帧(Stack Frame),即使有成百上千个并发请求在事件循环中交替执行,各自的 `sessionId` 也永远被封闭在自身的闭包作用域中,物理上绝不可能发生相互覆盖。
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 四、 Freya 的第二道防线:单链异步写排队锁 (Update Lock)
|
|
115
|
+
|
|
116
|
+
消除了内存变量串线后,底座还面临第二道物理风险 —— **并发写入覆写竞态**。
|
|
117
|
+
|
|
118
|
+
### 1. 竞态隐患
|
|
119
|
+
当一个会话同时触发多个工具返回,或者短时间内收到多个追加请求时:
|
|
120
|
+
- 异步写操作 1 读取了磁盘上的旧历史;
|
|
121
|
+
- 异步写操作 2 也读取了相同的旧历史;
|
|
122
|
+
- 两者分别 push 后各自存盘,后写入的就会把先写入的消息**静默覆盖**。
|
|
123
|
+
|
|
124
|
+
### 2. 现存代码的真实防御:Promise 队列锁
|
|
125
|
+
在 `packages/core/src/session/session-manager.ts` 中,Freya 使用 `updateLocks` 实现了极简而强大的会话级单链互斥锁:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// 记录每个会话当前的异步写操作锁 Promise
|
|
129
|
+
private updateLocks = new Map<string, Promise<unknown>>();
|
|
130
|
+
|
|
131
|
+
private async enqueueWrite<T>(sessionId: string, fn: (session: Session) => Promise<T>): Promise<T> {
|
|
132
|
+
// 1. 获取当前会话已挂接的锁 Promise,若无,初始化为已解决的 Promise
|
|
133
|
+
const current = this.updateLocks.get(sessionId) ?? Promise.resolve();
|
|
134
|
+
|
|
135
|
+
// 2. 利用 .then() 挂载新的写操作,使其在上一轮写入完全 resolve 后才启动
|
|
136
|
+
const next = current.then(async () => {
|
|
137
|
+
let session: Session | null = null;
|
|
138
|
+
try {
|
|
139
|
+
session = await this.lazyLoadSession(sessionId); // 确保读取到最新物理状态
|
|
140
|
+
} catch {}
|
|
141
|
+
return fn(session as any); // 执行真正的原子修改与落盘
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 3. 将新锁覆盖更新回 Map,形成单链挂载
|
|
145
|
+
this.updateLocks.set(sessionId, next);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
return await next; // 等待当前写任务排队完成
|
|
149
|
+
} finally {
|
|
150
|
+
// 4. 清理已执行完的锁,释放内存
|
|
151
|
+
if (this.updateLocks.get(sessionId) === next) {
|
|
152
|
+
this.updateLocks.delete(sessionId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
通过这套基于 Promise 链的内存协程锁,底座在不依赖任何外部重量级中间件的前提下,保证了同一会话下所有并发消息追加与状态落盘的**绝对原子性与时序一致性**。
|
|
159
|
+
|
|
160
|
+
本节我们通过显式栈帧隔离与 Promise 单链写排队锁,彻底理清了多会话并发下的防串线与写一致性防线。在下一章中,我们将进入上下文溢出危机,去攻克智能体大脑在长对话下面临的“Context Window 遗忘深渊”。
|
|
161
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "7.1 上下文溢出的毁灭性后果与窗口危机"
|
|
3
|
+
weight: 10
|
|
4
|
+
description: "揭密 Context Window 超限后的物理灾难,剖析静默截断引发的 System Loss 与 Token 费用恶性循环成因。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 7.1 上下文溢出的毁灭性后果与窗口危机
|
|
8
|
+
|
|
9
|
+
在 6.1 至 6.4 节中,我们建立了会话(Session)的生命周期管理,并通过显式传参无状态化与单链排队写锁规避了并发状态污染与写入竞态。
|
|
10
|
+
|
|
11
|
+
但是,随着用户与智能体之间对话的持续深入,另一个躲藏在底层的物理死敌开始浮出水面 —— **上下文窗口(Context Window)的物理极限溢出。**
|
|
12
|
+
|
|
13
|
+
如果我们在智能体底座中,只是一味地将新产生的对话历史 append 进数组,而不做任何“记忆垃圾回收”,一旦消息的总 Token 数突破了大模型的硬件天花板,整个智能体系统会瞬间发生不可逆的物理灾难。
|
|
14
|
+
|
|
15
|
+
本节我们将深入解剖上下文溢出时的物理崩溃路径,并探讨为什么记忆动态治理是 Agent 架构中不可或缺的“生存防线”。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 一、 Context Window 溢出时的两条物理崩溃路径
|
|
20
|
+
|
|
21
|
+
当 Message 历史数组的 Token 总量,加上 System Prompt 模板,以及大模型即将预测输出的 `max_tokens` 额度之和,超出了当前物理模型的最大窗口配额时,根据不同厂商网关的策略,会发生以下两种致命崩溃:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
[Token 总量溢出 Context Limit]
|
|
25
|
+
│
|
|
26
|
+
┌──────────────────────┴──────────────────────┐
|
|
27
|
+
▼ ▼
|
|
28
|
+
[路径 A: 网关抛错崩溃 (400)] [路径 B: 悄无声息的静默截断]
|
|
29
|
+
│ │
|
|
30
|
+
接口瘫痪,智能体瞬间死机 System Loss,记忆断层,逻辑崩塌
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 路径 A:网关无情拒绝服务 (HTTP 400/429)
|
|
34
|
+
这是最粗暴但也最容易排查的路径。OpenAI 或 Anthropic 等大厂网关在接收到请求时,如果发现输入 Token 数已经溢出,会直接返回 `400 BadRequest` 或 `429 RateLimit`:
|
|
35
|
+
`Error: context_length_exceeded (This model's maximum context length is 8192 tokens...)`
|
|
36
|
+
|
|
37
|
+
底座如果没有捕获该异常,整个多轮交互链条会立刻中断,用户屏幕直接弹窗报错,智能体宣告死机。
|
|
38
|
+
|
|
39
|
+
### 路径 B:网关静默截断 (Silent Truncation)
|
|
40
|
+
这是最诡异、最难排查,也是后果最具有**毁灭性**的路径。
|
|
41
|
+
|
|
42
|
+
某些云端中转网关(或本地部署的开源框架,如 LangChain 的某些默认链)为了防止报错,在发送请求给 LLM 之前,会**自作聪明地默默将输入消息列表最前端的几条历史记录删除**,直到 Token 消耗压缩进安全窗口内,然后再发给大模型。
|
|
43
|
+
|
|
44
|
+
大模型大脑在完全不知情的情况下,收到了一份“被撕掉了前几页的残缺剧本”。
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 二、 静默截断引发的三大物理灾难
|
|
49
|
+
|
|
50
|
+
当网关执行静默截断时,智能体会发生不可逆的逻辑退化:
|
|
51
|
+
|
|
52
|
+
### 1. 脑残式“记忆断层”
|
|
53
|
+
因为被截断的大多是位于数组最前端的“古老历史”。大模型收到的剧本里失去了最早的用户自我介绍。
|
|
54
|
+
* *场景*:用户聊着聊着,智能体突然问:“请问您的名字是?我能帮您做什么?”
|
|
55
|
+
* *后果*:极易让用户对智能体的智商产生严重质疑,心流体验彻底破产。
|
|
56
|
+
|
|
57
|
+
### 2. 系统规则崩塌(System Loss 灾难)
|
|
58
|
+
如果回传消息里的 System 消息(包含世界观、Tool 调用约束、安全限制)也不幸落在被截断的区域中。
|
|
59
|
+
* **物理下场**:大模型收到的输入纯粹变成了 User 与 Assistant 的聊天记录,**彻底丢失了导演剧本(System Prompt)**。
|
|
60
|
+
* 失去约束的大模型会完全忘掉自己“不能回答政治”、“必须使用 JSON 输出”等红线,甚至在接龙中开始把底层的敏感密钥和接口细节直接吐给用户,发生严重的安全泄露事故。
|
|
61
|
+
|
|
62
|
+
### 3. Token 账单与首字延时(TTFT)的恶性循环
|
|
63
|
+
即便网关没有报错也没有截断,在上下文极长(如 128k 满载)时运行多轮对话:
|
|
64
|
+
* **算力代价**:每一次大模型接龙,都必须通过 $O(N^2)$ 的自注意力机制重新扫描这 128k 的庞大历史。
|
|
65
|
+
* **后果**:首字时间(Time-to-First-Token)从正常的 0.5 秒直接飙升到 15 秒以上。更惨烈的是,由于输入 Token 极大,**你聊一句废话,大模型厂商都要向你收取整整 128k 的输入计费**,导致 Agent 在极短时间内烧光你整月的 API 额度,陷入财务深渊。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 三、 动态上下文检测在 Agent 底座中的生存意义
|
|
70
|
+
|
|
71
|
+
建立起上述物理认知后,我们必须认识到:**智能体底座绝对不能扮演一个“只管拼接、不管清理”的消息邮递员。**
|
|
72
|
+
|
|
73
|
+
记忆动态治理,是智能体底座的“生存防线”。底座必须像一个操作系统的垃圾回收器(Garbage Collector)一样,在每一次 while 循环决策环前:
|
|
74
|
+
1. **精确算账**:用分词器(Tokenizer)计算当前历史的 Token 大小。
|
|
75
|
+
2. **动态检测限制**:读取当前物理模型真实的 Context Window 上限。
|
|
76
|
+
3. **主动熔断压缩**:一旦检测到 Token 消耗逼近安全阈值(如最大窗口的 85%),必须立刻拦截主流程,启动后台压缩或滚动淘汰,把冗余的历史无损转换为“记忆摘要”,腾出宝贵的窗口空间。
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 四、 【调试与避坑】API 切换与本地量化模型的“窗口缩减”陷阱
|
|
81
|
+
|
|
82
|
+
在开发阶段,我们经常会在不同的模型提供商之间切换以测试效果:
|
|
83
|
+
* 本地测试时,使用的是云端的 **GPT-4o**(支持 **128,000** Token 的超大窗口)。
|
|
84
|
+
* 私有化部署时,为了省钱,自动降级切换到了本地部署的 **Llama-3-8B**(最大窗口仅有 **8,192** Token)。
|
|
85
|
+
|
|
86
|
+
### 1. 物理坠毁现象
|
|
87
|
+
如果你的底座中,写死了全局上下文压缩线为 `50,000` Token(这在 GPT-4o 面前是安全的)。
|
|
88
|
+
一旦部署到本地 Llama-3-8B 环境,因为 Llama-3 的物理天花板只有 8,192,当历史对话达到 9,000 Token 时,底座依然傻傻地等待着 50,000 压缩线,从而直接在第一步将整台服务器瞬间撞毁 OOM。
|
|
89
|
+
|
|
90
|
+
### 2. 避坑策略:动态窗口探针 API
|
|
91
|
+
底座必须在每次切换模型时,通过 `llm.getContextWindow(effectiveModelId)` 动态抓取当前被激活模型的真实物理配额,并以该配额的百分比(如 $85\%$)动态计算压缩水位线:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// 摘自 packages/core/src/session/compactor.ts
|
|
95
|
+
const effectiveModelId = modelId || 'default-model';
|
|
96
|
+
const contextWindow = this.llm.getContextWindow(effectiveModelId); // 动态获取 8k 或 128k
|
|
97
|
+
|
|
98
|
+
const preThreshold = cmConfig.preCompressThreshold ?? 0.85;
|
|
99
|
+
const shouldCompressByToken = totalEstimatedTokens > contextWindow * preThreshold;
|
|
100
|
+
|
|
101
|
+
if (shouldCompressByToken) {
|
|
102
|
+
// 触发安全压缩拦截
|
|
103
|
+
const compResult = await this.compactor.compressIfNeeded(session, history, modelId);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
通过这一层动态窗口探针设计,无论我们是将大模型向上升级还是向下降级,底座的记忆治理模块都能自适应地在物理悬崖边缘拉起警戒线,保护 Agent 永不崩溃。
|
|
108
|
+
|
|
109
|
+
在下一小节中,我们将对比研究拯救上下文窗口的两大算法流派 —— 滑动窗口法与递归摘要法。
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "7.2 上下文压缩机制:滑动窗口与递归摘要算法"
|
|
3
|
+
weight: 20
|
|
4
|
+
description: "对比滑动窗口淘汰法与递归摘要归档法的技术原理与权衡,设计防御“细节磨灭”的高质量摘要 Prompt 模板。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 7.2 上下文压缩机制:滑动窗口与递归摘要算法
|
|
8
|
+
|
|
9
|
+
在 7.1 节中,我们了解了长对话超出 Context Window 时对智能体大脑造成的毁灭性灾难。为了物理防御这一问题,底座必须充当“记忆治理器”。
|
|
10
|
+
|
|
11
|
+
在当前的 Agent 工程实践中,拯救上下文窗口主要有两种技术流派:**滑动窗口淘汰法(Sliding Window)** 与 **主动递归摘要法(Recursive Summarization)**。
|
|
12
|
+
|
|
13
|
+
它们一个代表了“物理性的遗忘”,另一个代表了“大脑的主动归档”。本节我们将对比解剖这两大算法的设计原理、权衡,并针对摘要机制中的核心缺陷提供防御性的解决方案。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 一、 滑动窗口淘汰法 (Sliding Window):截断与忘却
|
|
18
|
+
|
|
19
|
+
滑动窗口淘汰法是一种非常直接的“空间释放”策略。它就像一个 FIFO(先进先出)的固定长度队列。
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
[System Prompt] ── [被踢出的旧消息] <── [ 保 留 的 活 跃 窗 口 (最近 N 轮) ] <── [新进消息]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 1. 技术实现原理
|
|
26
|
+
底座设置一个最大消息行数限制 $N$(如 20 行),或者计算 Token 大小。当超过限制时,底座会在回传历史前,**无情地从消息数组的头部(System 消息之后)剔除多余的旧消息**。
|
|
27
|
+
|
|
28
|
+
### 2. 优缺点评估
|
|
29
|
+
* **优点(运行零开销)**:
|
|
30
|
+
实现极其简单,不需要额外调用大模型去生成整理文本,不产生任何额外的 API 账单费用,执行速度在毫秒级。
|
|
31
|
+
* **缺点(物理性硬遗忘)**:
|
|
32
|
+
这是一种粗暴的遗忘。一旦对话进入第 30 轮,用户在第 2 轮交代的核心账户 ID、偏好设置或刚才修改完的临时代码,会被滑动窗口直接推出边界,彻底被系统遗忘。这对于执行长线复杂业务的智能体来说是无法接受的。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 二、 主动递归摘要法 (Recursive Summarization):主动归档
|
|
37
|
+
|
|
38
|
+
递归摘要法模拟了人类大脑在睡眠时对白天的记忆进行“碎片整理和提炼归档”的过程。
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
[第 1~20 轮对话] ──> 【调用后台大模型摘要】 ──> 生成 summaryText
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
[新的 Context] ──> [System Prompt] + [summaryText (历史回顾)] + [第 21 轮之后消息]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 1. 技术实现原理
|
|
48
|
+
当检测到 Token 逼近安全水位线时,底座执行以下操作:
|
|
49
|
+
1. **截取与保留**:保留最近的 $K$ 条活跃对话消息(如最近 5 条),截取此前的所有老历史消息。
|
|
50
|
+
2. **摘要合成**:将这批老历史送入大模型,配以专用的归档提示词,合成一段紧凑的摘要:`“小明在寻找 101 号订单的退款方法,智能体已调用查询工具,确认退款已到账,小明接下来想修改收货地址。”`
|
|
51
|
+
3. **消息清空与反向插入**:在物理 history 数组中删除这批老历史,织造一条特制的 User 消息:`[此前对话摘要:... ]` 插入到 System 消息之后。
|
|
52
|
+
4. **螺旋递归**:当下一次窗口再次满载,底座将“上一次的旧摘要 + 新的前半段对话”再次打包融合成“新摘要”,螺旋上升,保持上下文总量永远在极小规模。
|
|
53
|
+
|
|
54
|
+
### 2. 优缺点评估
|
|
55
|
+
* **优点(认知连续性)**:
|
|
56
|
+
旧的历史细节虽然丢了,但**核心的事实、已做出的决策和当前的业务目标被浓缩保存了**。大模型能始终承接长线上下文逻辑,智商表现极其稳定。
|
|
57
|
+
* **缺点(二次账单与细节磨灭)**:
|
|
58
|
+
为了合成摘要,底座必须频繁地在后台异步调用大模型,这会产生额外的 API Token 费用。更致命的是,摘要由大模型生成,如果摘要 Prompt 写得不好,大模型极易在提炼时丢失重要的具体数据(如 IP 地址、文件路径或金额数字),发生“细节磨灭”。
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 三、 【架构防御】防御“事实磨灭与时序失真”的摘要模板设计
|
|
63
|
+
|
|
64
|
+
在实际智能体运行中,递归摘要法如果提示词不当,极易引发以下两类核心缺陷:
|
|
65
|
+
* **事实丢失**:对话中的具体结论或行动被大模型泛化模糊处理(如将“用户修改了配置文件 config.json 中的 port 为 8080”粗略概括为“用户进行了配置调整”)。
|
|
66
|
+
* **时序矛盾**:增量融合时新旧决策倒错,导致大模型在后续决策中产生自相矛盾的逻辑幻觉。
|
|
67
|
+
|
|
68
|
+
### 🌟 物理模板规范:`core.prompt.summarize_guidance.md`
|
|
69
|
+
根据 Freya 的“零硬编码提示词(Zero Hardcoded Prompt)”规范,摘要提炼指令独立存放于物理文件 `packages/core/config/prompts/core.prompt.summarize_guidance.md` 中,通过提示词注册表动态加载:
|
|
70
|
+
|
|
71
|
+
```markdown
|
|
72
|
+
请将上述对话中的核心交互关键信息提炼为一段极简的背景摘要(50字以内),作为下一步推理的参考背景。
|
|
73
|
+
如果当前已经存在先前的背景提要,请将新内容增量融合成一段连贯的新提要。
|
|
74
|
+
|
|
75
|
+
【摘要提炼要求】
|
|
76
|
+
1. 事实聚焦:聚焦于已发生的具体行动、得出的核心结论、当前的状态以及待办的事实,去除所有的口水话、客套辞令及推理过程。
|
|
77
|
+
2. 保持连贯:增量融合时,要确保时序正确、逻辑紧凑,避免重复陈述或产生自相矛盾的信息。
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 物理设计优势:
|
|
81
|
+
1. **去口水话与极致紧凑**:通过“50字以内”与“去除推理过程”约束,将历史背景的 Token 开销压缩到极低基线,为后续新工具调用预留足够的窗口余量。
|
|
82
|
+
2. **时序与结论锚定**:明确要求“增量融合时确保时序正确”,避免反复压缩导致前后因果断裂,最大化保障智能体在超长多轮对话中的认知稳定性。
|
|
83
|
+
|
|
84
|
+
在下一小节中,我们将实际切入 Freya 的源码,看看 `SessionCompactor` 是如何具体在 TS 代码层面执行快照保存与老历史淘汰的。
|
|
85
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "7.3 【白盒剖析】Compactor 与淘汰算法"
|
|
3
|
+
weight: 30
|
|
4
|
+
description: "白盒解剖 Freya 的 compactor.ts 源码,拆解双轨压缩判定、因果消息链安全截断点回溯算法与轻量 Token 估算器设计。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 7.3 【白盒剖析】Compactor 与淘汰算法
|
|
8
|
+
|
|
9
|
+
在 7.2 节中,我们对比了滑动窗口淘汰与递归摘要在拯救上下文溢出危机时的原理与权衡。本节我们将实际切入 Freya 的记忆压缩器核心模块(位于 `packages/core/src/session/compactor.ts`)的 TypeScript 源码。
|
|
10
|
+
|
|
11
|
+
我们将白盒解剖它是如何通过**双轨压缩判定**实现性能与安全的平衡、如何通过**安全截断点回溯算法(Safe Truncate Index)**物理防御 Tool Call 消息因果断层,以及如何实现**轻量级正则 Token 预估器**以节省 CPU 算力的。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 一、 双轨压缩判定机制:前置防爆 vs 后置静默
|
|
16
|
+
|
|
17
|
+
在 `SessionCompactor` 中,记忆压缩不是单一触发的,而是分为**前置同步强拦截**和**后置异步静默整理**双轨运行。
|
|
18
|
+
|
|
19
|
+
### 1. 前置安全强拦截 `compressIfNeeded()`
|
|
20
|
+
* **触发水位**:默认高达 `preCompressThreshold: 0.85`(已使用 85% 窗口限制)。
|
|
21
|
+
* **工作物理路径**:在向 LLM 投递请求的最后一步同步运行。一旦预估 Token 超过 85%,底座会强行挂起(`await`)当前的推理流程,必须在本地同步生成完摘要并裁剪完历史后,才允许发送网络包。这是防御网关 400 报错的**同步最后防线**。
|
|
22
|
+
|
|
23
|
+
### 2. 后置后台异步整理 `compressPostChat()`
|
|
24
|
+
* **触发水位**:默认仅为 `postCompressThreshold: 0.65`(已使用 65% 窗口限制)。
|
|
25
|
+
* **工作物理路径**:在大模型回复成功写入 Session 之后运行。由于 65% 水位处于安全区间,底座**绝不阻塞**当前请求,而是启动一个非阻塞的异步 `Promise.then` 挂载到后台锁中静默执行压缩整理。当用户看到字出来时,底座在后台已经默默把老历史整理完毕,保证了交互的心流体验。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 二、 物理防线:安全截断点回溯算法 (Safe Truncate Index)
|
|
30
|
+
|
|
31
|
+
在对会话历史(Messages 数组)进行裁剪(Slice)时,很多人会直接调用 `history.slice(safeIndex)`。
|
|
32
|
+
|
|
33
|
+
### 1. 致命的 Tool Call “意图-结果”因果链断开
|
|
34
|
+
在 5.1 节中我们确立了,`assistant` 发起的 `tool_calls` 与紧随其后的 `tool` Observation 结果在协议上具有不可分割的对偶性。
|
|
35
|
+
如果我们的截断点恰好落在它们中间:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
[被裁剪抛弃的区域] ───────> | [assistant: tool_calls (ID: call_101)]
|
|
39
|
+
======================== 截断切刀 ========================
|
|
40
|
+
[保留发送给 LLM 的区域] ───> | [tool: Observation (ID: call_101)]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
大模型收到历史后,会发现有一条“凭空出现的 tool 结果”而缺乏上文的 `tool_calls` 意图,大厂网关会立刻抛出协议 400 错误。
|
|
44
|
+
|
|
45
|
+
### 2. Freya 的解决方案:`findSafeTruncateIndex`
|
|
46
|
+
为了防御这一灾难,`compactor.ts` 中设计了极其巧妙的**向前回溯对齐算法**:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
function findSafeTruncateIndex(history: LLMMessage[], keepTurns: number): number {
|
|
50
|
+
const keepCount = keepTurns * 2;
|
|
51
|
+
if (history.length <= keepCount) return 0;
|
|
52
|
+
|
|
53
|
+
// 1. 根据保留轮数粗算一个裁剪索引
|
|
54
|
+
let targetIndex = history.length - keepCount;
|
|
55
|
+
|
|
56
|
+
// 2. 💡 物理回溯防线:从粗算点开始向前(老历史方向)扫描
|
|
57
|
+
while (targetIndex > 0) {
|
|
58
|
+
const currentMsg = history[targetIndex];
|
|
59
|
+
const prevMsg = history[targetIndex - 1];
|
|
60
|
+
|
|
61
|
+
const isCurrentTool = currentMsg.role === 'tool';
|
|
62
|
+
const isPrevAssistantWithTools =
|
|
63
|
+
prevMsg && prevMsg.role === 'assistant' && !!(prevMsg.toolCalls && prevMsg.toolCalls.length > 0);
|
|
64
|
+
|
|
65
|
+
// 如果当前截断边界落在 tool 消息,或前一条是带意图的助手消息
|
|
66
|
+
// 说明此时正处于一个 Tool Call 因果消息对的“肚子”里
|
|
67
|
+
if (isCurrentTool || isPrevAssistantWithTools) {
|
|
68
|
+
targetIndex--; // 强制将切刀向历史更古老的方向推进,直到跨越该因果对
|
|
69
|
+
} else {
|
|
70
|
+
break; // 越过因果对,切刀落在了干净的 User 边界上,安全退出
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. 边界补充校验:防止截断在 user 和并发 toolCalls 中间
|
|
75
|
+
if (targetIndex > 0) {
|
|
76
|
+
const boundaryMsg = history[targetIndex];
|
|
77
|
+
const prevMsg = history[targetIndex - 1];
|
|
78
|
+
if (boundaryMsg.role === 'assistant' && boundaryMsg.toolCalls && boundaryMsg.toolCalls.length > 0) {
|
|
79
|
+
if (prevMsg && prevMsg.role === 'user') {
|
|
80
|
+
targetIndex--;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return targetIndex; // 返回绝对安全的物理裁剪点
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
通过这一层回溯机制,切刀被强制纠正,确保裁剪出来的每一段历史都是逻辑自洽的,在物理上杜绝了多轮工具消息断层引发的崩溃。
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 三、 零额外 CPU 负载:轻量级正则 Token 预估器
|
|
94
|
+
|
|
95
|
+
为了判定是否触及 85% 压缩线,底座必须频繁估算当前历史的 Token 数。如果每次都调用 Tiktoken C++ 分词包,会导致高并发下服务器的 CPU 负载急剧飙升。
|
|
96
|
+
|
|
97
|
+
`compactor.ts` 采用了一套极为高超的**正则字符特征预估器**:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
export function estimateMessageTokens(msg: LLMMessage): number {
|
|
101
|
+
let tokens = 0;
|
|
102
|
+
if (msg.content) {
|
|
103
|
+
// 1. 英文与数字 Token:匹配连续单词数,权重系数设为 1.3
|
|
104
|
+
const englishWords = msg.content.match(/[a-zA-Z0-9_]+/g) || [];
|
|
105
|
+
tokens += englishWords.length * 1.3;
|
|
106
|
+
|
|
107
|
+
// 2. 中文字符 Token:匹配汉字个数,根据 UTF-8 编码密度,权重系数设为 1.8
|
|
108
|
+
const chineseChars = msg.content.match(/[\u4e00-\u9fa5]/g) || [];
|
|
109
|
+
tokens += chineseChars.length * 1.8;
|
|
110
|
+
|
|
111
|
+
// 3. 特殊控制符、标点与空格:权重系数设为 0.5
|
|
112
|
+
const otherText = msg.content.replace(/[a-zA-Z0-9_]/g, '').replace(/[\u4e00-\u9fa5]/g, '');
|
|
113
|
+
tokens += otherText.length * 0.5;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. 多模态附件:单张低分辨率图片默认折算为 200 Token
|
|
117
|
+
if (msg.attachments) {
|
|
118
|
+
const imageAttachments = msg.attachments.filter(
|
|
119
|
+
(a) => a.mimeType.startsWith('image/') || a.type === 'image',
|
|
120
|
+
);
|
|
121
|
+
tokens += imageAttachments.length * 200;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 5. 工具调用定义与参数字段评估
|
|
125
|
+
if (msg.toolCalls) {
|
|
126
|
+
for (const tc of msg.toolCalls) {
|
|
127
|
+
tokens += 20; // 基准 Token 开销
|
|
128
|
+
if (tc.arguments) {
|
|
129
|
+
const englishWords = tc.arguments.match(/[a-zA-Z0-9_]+/g) || [];
|
|
130
|
+
tokens += englishWords.length * 1.3;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Math.ceil(tokens); // 向上取整
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 物理评估效果:
|
|
140
|
+
这套正则估算器拥有接近 $0$ 的 CPU 开销。在实际的压测对比中,其估算出来的 Token 数与 Tiktoken 原生分词结果的偏差**小于 5%**,完全能作为安全水位判定的可靠物理雷达,实现了极致的性能优化。
|
|
141
|
+
|
|
142
|
+
本节我们白盒看清了双轨判定、因果链安全回溯与轻量正则 Token 估算器。在下一节中,我们将亲自在本地调试由于递归摘要拼装引起的“套娃式”上下文崩溃经典 Bug。
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "7.4 调试与避坑指南:递归摘要“套娃死锁”防御"
|
|
3
|
+
weight: 40
|
|
4
|
+
description: "实战调试上下文压缩引发的套娃死锁灾难,设计单轮防爆限制计数器与强制降级滑动截断的物理防御策略。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 7.4 调试与避坑指南:递归摘要“套娃死锁”防御
|
|
8
|
+
|
|
9
|
+
在前几节中,我们研究了滑动窗口与递归摘要的利弊,并白盒剖析了 `SessionCompactor` 的安全回溯截断与 Token 正则预估算法。
|
|
10
|
+
|
|
11
|
+
在真实的智能体长对话业务中,递归摘要虽然优雅地维护了智能体大脑的“长线记忆”,但它在极端物理边界下,会诱发一个让系统架构师彻夜难眠的**“套娃式”上下文压缩死锁(Summarization Loop Deadlock)**:
|
|
12
|
+
* 对话总量超标,底座拦截并调用大模型进行提炼归档。
|
|
13
|
+
* 提炼生成的新摘要由于大模型“多嘴”字数偏多,再加上必须保留的活跃消息,重算后的 Token **依然在安全线以上**。
|
|
14
|
+
* 底座在下一微秒以为还没有压缩成功,**再次触发压缩调用**...
|
|
15
|
+
* 智能体陷入“自己给自己写摘要”的无尽虚空死循环中,API 账单疯狂空转,直至被大厂网关彻底封号拉黑。
|
|
16
|
+
|
|
17
|
+
本节我们将实际在本地复现这一“套娃死锁”,并设计多重物理防御防线,保护智能体大脑永不宕机。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 一、 递归摘要“套娃死锁”的触发物理过程
|
|
22
|
+
|
|
23
|
+
大模型的摘要提炼也是一种自回归生成。如果底座对摘要生成的最大 Token 限制不够严格,或者保留的活跃轮数(`keepRecentTurns`)太长,就会在临界点引爆死锁:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
[Token 消耗 > 85%] ──> 触发前置压缩 ──> 生成 500 Token 的摘要文本
|
|
27
|
+
│
|
|
28
|
+
▼ (重算上下文 Token)
|
|
29
|
+
[Token 消耗仍 > 85%] <── 判定依然超限 ──> 再次触发压缩 ──> 生成 600 Token 的摘要文本 (套娃死循环!)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
在这个死循环中,智能体完全丧失了对用户正常提问的响应能力,网络 I/O 陷入狂暴的空转,是危害性极高的“拒绝服务(DoS)”隐患。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 二、 本地调试:复现“套娃死锁”
|
|
37
|
+
|
|
38
|
+
我们在本地模拟一个极小上下文窗口(如 1000 Token 限额)下的记忆提炼过程,并故意使摘要长度膨胀,以复现死锁:
|
|
39
|
+
|
|
40
|
+
### 1. 套娃死锁复现脚本 (deadlock_test.js)
|
|
41
|
+
```javascript
|
|
42
|
+
const CONTEXT_LIMIT = 1000;
|
|
43
|
+
const WATERMARK = 800; // 80% 水位线
|
|
44
|
+
|
|
45
|
+
// 模拟的会话历史
|
|
46
|
+
let sessionHistory = [
|
|
47
|
+
{ role: "user", content: "A".repeat(500) }, // 500 Token 消息
|
|
48
|
+
{ role: "assistant", content: "B".repeat(200) } // 200 Token 消息
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
let summary = "";
|
|
52
|
+
|
|
53
|
+
// 模拟的大模型摘要提炼函数 (因逻辑发散,生成的摘要太冗长)
|
|
54
|
+
async function mockLlmSummarize(historyText, prevSummary) {
|
|
55
|
+
console.log("🤖 [LLM 提炼中...]");
|
|
56
|
+
// 故意返回一个高达 450 Token 的冗长摘要,夹杂大量废话
|
|
57
|
+
return "Detailed_Summary_Text_With_Garbage_Characters_" + "X".repeat(400);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 模拟底座的前置同步压缩自检函数
|
|
61
|
+
async function compressCheck() {
|
|
62
|
+
let loops = 0;
|
|
63
|
+
|
|
64
|
+
while (true) {
|
|
65
|
+
loops++;
|
|
66
|
+
const currentTokens = sessionHistory.reduce((sum, m) => sum + m.content.length, 0) + summary.length;
|
|
67
|
+
console.log(`\n--- 检查第 ${loops} 轮 | 当前预估 Token 消耗: ${currentTokens}/${CONTEXT_LIMIT} ---`);
|
|
68
|
+
|
|
69
|
+
if (currentTokens <= WATERMARK) {
|
|
70
|
+
console.log("✅ 处于安全水位线以下,允许向用户返回响应。");
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`⚠️ 水位超限 (当前 ${currentTokens} > 安全线 ${WATERMARK}),触发前置同步压缩!`);
|
|
75
|
+
|
|
76
|
+
// 模拟裁剪掉前一半老消息,生成摘要
|
|
77
|
+
const oldHistory = sessionHistory.splice(0, 1);
|
|
78
|
+
const historyText = oldHistory[0].content;
|
|
79
|
+
|
|
80
|
+
// 💡 异步生成摘要并注入
|
|
81
|
+
const newSummary = await mockLlmSummarize(historyText, summary);
|
|
82
|
+
summary = newSummary;
|
|
83
|
+
|
|
84
|
+
// 重新将摘要包裹为一条系统回顾塞回历史头部
|
|
85
|
+
sessionHistory.unshift({
|
|
86
|
+
role: "user",
|
|
87
|
+
content: `[此前对话摘要回顾]: ${summary}`
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (loops > 5) {
|
|
91
|
+
console.error("🚨🚨🚨 [套娃死锁确证] 压缩陷入死循环!智能体大脑已彻底瘫痪。");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
compressCheck();
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 2. 测试输出分析
|
|
101
|
+
运行此脚本后,你会眼睁睁看着控制台在两轮迭代后,Token 不减反增,迅速陷入了无限次触发 `⚠️ 水位超限,触发前置同步压缩` 的死锁怪圈,直奔崩溃而去。
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 三、 现存源码防线:单次确定性判定与异常截断兜底
|
|
106
|
+
|
|
107
|
+
为了从根源上彻底斩断死锁链条,Freya 在 `SessionCompactor`(位于 `packages/core/src/session/compactor.ts`)中建立了严密的**“单次确定性执行 + 异常降级滑动截断”**架构防线:
|
|
108
|
+
|
|
109
|
+
### 1. 架构根源绝杀:消除 `while` 压缩循环
|
|
110
|
+
在真实的 `compactor.ts` 中,`compressIfNeeded` 采用单次无循环判定。每一次消息追加仅触发一次水位检测与处理,绝不在局部开启任何 `while` 式反复摘要循环,从架构设计根源上消除死循环的土壤。
|
|
111
|
+
|
|
112
|
+
### 2. 异常自动降级滑动截断 (Fallback Truncation)
|
|
113
|
+
如果大模型在生成摘要时发生异常、超时,或者生成的摘要文本为空:
|
|
114
|
+
底座会在 `catch` 块中**自动放弃摘要,无条件降级为滑动窗口截断模式** —— 调用 `findSafeTruncateIndex` 与 `truncateHistory` 直接裁剪历史,确保系统 100% 存活,不阻塞主流程向大模型发包。
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// 摘自 packages/core/src/session/compactor.ts
|
|
118
|
+
try {
|
|
119
|
+
const historyToCompress = history.slice(0, safeTruncateIndex);
|
|
120
|
+
const newSummary = await this.executeSummarize(session, historyToCompress, currentSummary);
|
|
121
|
+
|
|
122
|
+
if (!newSummary || newSummary.trim() === '') {
|
|
123
|
+
this.logger?.warn(`[SessionCompactor] 前置压缩生成的摘要为空,放弃应用。`);
|
|
124
|
+
return { type: 'none' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 正常生成快照并打标塞入历史头部
|
|
128
|
+
const snapFile = this.buildSnapshot(session, newSummary, historyToCompress);
|
|
129
|
+
...
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
this.logger?.error('[SessionCompactor] 触发对话历史压缩管理失败:', err);
|
|
132
|
+
try {
|
|
133
|
+
// 💡 核心安全防线:摘要异常时,无条件触发兜底滑动窗口截断
|
|
134
|
+
const keepTurns = cmConfig.keepRecentTurns || 6;
|
|
135
|
+
const safeTruncateIndex = findSafeTruncateIndex(history, keepTurns);
|
|
136
|
+
if (safeTruncateIndex > 0) {
|
|
137
|
+
this.logger?.warn(`[SessionCompactor] 摘要生成失败,执行兜底滑动窗口截断`);
|
|
138
|
+
this.truncateHistory(history, safeTruncateIndex);
|
|
139
|
+
return { type: 'truncated' };
|
|
140
|
+
}
|
|
141
|
+
} catch (fallbackErr: any) {
|
|
142
|
+
this.logger?.error('[SessionCompactor] 兜底滑动窗口截断也失败:', fallbackErr);
|
|
143
|
+
}
|
|
144
|
+
return { type: 'none' };
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 四、 避坑经验:设置摘要大雪崩的 Token 边界保护
|
|
151
|
+
|
|
152
|
+
除了上述两道机制性防线,还有三个细微的物理参数限制,从根源上防范了摘要失控:
|
|
153
|
+
|
|
154
|
+
1. **summaryMaxTokens 限制**:在调用 LLM 进行摘要时,底座在模型参数中强行指定 `maxTokens: 150`。利用推理引擎底座的输出 Token 限制从物理上掐断大模型的输出长度,逼迫其精炼,防止摘要文本体积膨胀。
|
|
155
|
+
2. **增量融合与去重**:当历史摘要被带入下一次摘要计算时,Prompt 中明确要求“如果当前已经存在先前的背景提要,请将新内容增量融合成一段连贯的新提要”,避免重复陈述。
|
|
156
|
+
3. **快照独立存盘备份**:在裁剪历史前,底座先调用 `persistence.saveSnapshot` 将被裁剪掉的完整消息存入 `~/.freya/data/sessions/<uuid>/<snapId>.json` 独立物理快照文件。这为后续历史回溯提供了物理备份,即便滑动截断丢弃了老消息,原始对话依然完好保存在磁盘快照中。
|
|
157
|
+
|
|
158
|
+
通过本节的单次确定性防线、滑动截断降级兜底与快照备份学习,我们彻底攻克了智能体短期记忆系统中最核心的上下文溢出死锁 Bug。
|
|
159
|
+
|
|
160
|
+
在下一部分中,我们将跨越智能体心智与记忆的基石,进入最让人兴奋的交互体验流派 —— 流式通信(Streaming)与 EventEmitter 事件驱动。
|