@eoasmxd/freya 0.3.0 → 0.4.1
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/dist/command/commands/skill-commands.js +5 -4
- package/core/dist/config/config-manager.d.ts +5 -1
- package/core/dist/config/config-manager.js +13 -1
- package/core/dist/kernel.js +2 -2
- package/core/dist/skill/skill-registry.d.ts +12 -2
- package/core/dist/skill/skill-registry.js +89 -8
- package/core/dist/tools/meta/index.d.ts +3 -1
- package/core/dist/tools/meta/index.js +9 -1
- package/core/dist/web/config-api.js +18 -0
- 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 +3 -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-mysql/config/prompts/plugin.prompt.mysql.md +9 -0
- package/plugins/plugin-tool-mysql/config/prompts/plugin.prompt.mysql.select.audit.md +26 -0
- package/plugins/plugin-tool-mysql/dist/audit.d.ts +13 -0
- package/plugins/plugin-tool-mysql/dist/audit.js +87 -0
- package/plugins/plugin-tool-mysql/dist/index.d.ts +14 -0
- package/plugins/plugin-tool-mysql/dist/index.js +34 -0
- package/plugins/plugin-tool-mysql/dist/pool-manager.d.ts +32 -0
- package/plugins/plugin-tool-mysql/dist/pool-manager.js +113 -0
- package/plugins/plugin-tool-mysql/dist/tools.d.ts +11 -0
- package/plugins/plugin-tool-mysql/dist/tools.js +88 -0
- package/plugins/plugin-tool-mysql/package.json +33 -0
- package/plugins/plugin-tool-mysql/schema.json +83 -0
- 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
- package/src/packages/core/src/command/commands/skill-commands.ts +6 -5
- package/src/packages/core/src/config/config-manager.ts +15 -1
- package/src/packages/core/src/kernel.ts +3 -2
- package/src/packages/core/src/skill/skill-registry.ts +100 -8
- package/src/packages/core/src/tools/meta/index.ts +9 -1
- package/src/packages/core/src/web/config-api.ts +20 -0
- package/src/packages/ui/src/features/config/ConfigModal.tsx +13 -1
- package/src/packages/ui/src/features/config/panels/SkillConfigPanel.tsx +137 -0
- package/src/plugins/plugin-tool-mysql/src/audit.ts +103 -0
- package/src/plugins/plugin-tool-mysql/src/index.ts +44 -0
- package/src/plugins/plugin-tool-mysql/src/pool-manager.ts +132 -0
- package/src/plugins/plugin-tool-mysql/src/tools.ts +100 -0
- package/ui/assets/{index-Be0cAgdB.js → index-BqPQMflk.js} +14 -14
- package/ui/index.html +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "9.1 链路级刹车机制"
|
|
3
|
+
weight: 10
|
|
4
|
+
description: "探秘智能体交互中的紧急刹车(Interruptibility)痛点,解析基于 AbortSignal 的网络、执行器与工具三层物理控制网。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 9.1 链路级刹车机制
|
|
8
|
+
|
|
9
|
+
在大模型流式交互和 ReAct 循环执行中,我们经常会遭遇以下三种大模型“暴走”的物理场景:
|
|
10
|
+
* **大模型“抽风”**:大模型开始在屏幕上流式输出一万行毫无意义的乱码或者重复文字(鬼打墙)。
|
|
11
|
+
* **工具执行过慢**:智能体在后台调用了一个极为缓慢的数据库查询接口,已经转圈了 10 秒。
|
|
12
|
+
* **用户改变了主意**:智能体打字打到一半,用户突然意识到自己提问打错字了,在前端紧急点击了“停止生成(Stop)”按钮。
|
|
13
|
+
|
|
14
|
+
如果在底座架构中,我们没有建立起一套**链路级的紧急刹车机制**:
|
|
15
|
+
* 即使前端用户关掉了网页,大模型在云端服务器依然会继续推理,**疯狂燃烧你的 Token,直到输出额度爆满,产生高昂账单**。
|
|
16
|
+
* 底座正在进行的慢速本地工具依然会顽固地把 CPU 和内存占满,直到运行结束,这会产生大量无用的“僵尸计算”。
|
|
17
|
+
|
|
18
|
+
为了在物理层面上解决智能体交互链路中的紧急中断控制,底座必须建立起基于 **`AbortController` / `AbortSignal`** 的刹车网络。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 一、 AbortSignal:贯穿大脑与躯体的“物理刹车拉线”
|
|
23
|
+
|
|
24
|
+
在 Web 标准中,`AbortController` 提供了对异步操作的通用中断接口。在 Freya 底座中,`AbortSignal` 被当成了一根**贯穿整个智能体神经网络、决策执行器和本地工具进程的核心刹车拉线**:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
[ 用户点击 "停止" 按钮 ]
|
|
28
|
+
│
|
|
29
|
+
▼ (触发 AbortController.abort())
|
|
30
|
+
┌───────────────────┴───────────────────┐
|
|
31
|
+
▼ ▼
|
|
32
|
+
[网络传输层 (Fetch)] [核心执行器层 (ReAct)]
|
|
33
|
+
│ │
|
|
34
|
+
TCP RST 包强制重置网络连接 while(loop) 中断逻辑因果链
|
|
35
|
+
│ │
|
|
36
|
+
└───────────────────┬───────────────────┘
|
|
37
|
+
▼
|
|
38
|
+
[本地工具层 (Tool Process)]
|
|
39
|
+
│
|
|
40
|
+
销毁僵尸子进程与文件流
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 1. 第一层:网络传输层 (Network Pipe)
|
|
44
|
+
当底座向大模型 API 发起 SSE 流式 fetch 请求时,必须将 `signal` 作为配置参数传入:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
|
|
49
|
+
// 💡 物理挂接:将刹车拉线绑定到 fetch 请求中
|
|
50
|
+
const response = await fetch(requestUrl, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify(requestBody),
|
|
53
|
+
signal: controller.signal // 绑定信号
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
当控制器执行 `controller.abort()` 时,Node.js 底层网络库会在物理上立刻向大模型 API 服务器发送一个 **TCP RST (复位) 重置数据包**。
|
|
58
|
+
这会瞬间强行切断与大模型的物理 TCP 连接,迫使云端 API 服务器瞬间终止推理,从根本上锁死 Token 计费。
|
|
59
|
+
|
|
60
|
+
### 2. 第二层:核心执行器层 (Executor Loop)
|
|
61
|
+
在 2.3 节我们剖析了核心执行器的 `while(loop)` 自循环。在并发工具调度或多步迭代中,执行器必须在每一步的开头以及 `await` 之后,高频检查刹车信号的物理状态:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// 💡 物理自检:如果信号已经被拉起,立刻退出 ReAct 死循环
|
|
65
|
+
if (this.context.signal.aborted) {
|
|
66
|
+
this.logger.info("[AgentExecutor] 检测到链路刹车信号,紧急退出决策循环。");
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3. 第三层:本地工具层 (Tool Execution)
|
|
72
|
+
底座在调度本地工具执行时,会将当前的 `context`(包含 `signal` 句柄)作为执行上下文注入函数。
|
|
73
|
+
工具内部(例如执行大文件下载或数据库查询)在进行耗时操作时,也必须对该信号进行监听自检,或者将其向下透传给如 `child_process` 子进程或数据库驱动,确保手动刹车时能立刻释放操作系统 CPU 算力,防止出现僵尸进程。
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 二、 底座层“多路刹车分发”的设计:`FreyaAgentService`
|
|
78
|
+
|
|
79
|
+
在大并发的多会话智能体环境中,我们绝不能使用全局共享的 `AbortController`,否则会话 A 触发停止,会导致正在运行的会话 B 被无差别误杀。
|
|
80
|
+
|
|
81
|
+
在 Freya 源码 `packages/core/src/agent/agent-service.ts` 中,底座通过 `abortControllers` Map 维护每个会话的专属控制器,并借助 EventBus 实现完全解耦的**事件驱动中断分发**:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// 摘自 packages/core/src/agent/agent-service.ts
|
|
85
|
+
export class FreyaAgentService {
|
|
86
|
+
// 维护每个活跃会话的独立中断控制器
|
|
87
|
+
private abortControllers = new Map<string, AbortController>();
|
|
88
|
+
|
|
89
|
+
private setupListeners(): void {
|
|
90
|
+
// 监听总线派发的中断事件
|
|
91
|
+
this.context.eventBus.on('session:interrupt', (payload: { sessionId: string }) => {
|
|
92
|
+
// 1. 清空当前会话尚未处理的消息队列
|
|
93
|
+
this.messageQueues.delete(payload.sessionId);
|
|
94
|
+
|
|
95
|
+
// 2. 提取当前主会话的控制器并触发强行中断
|
|
96
|
+
const controller = this.abortControllers.get(payload.sessionId);
|
|
97
|
+
if (controller) {
|
|
98
|
+
controller.abort();
|
|
99
|
+
this.context.logger.warn(`[AgentService] 已打断会话 ${payload.sessionId} 的生成流。`);
|
|
100
|
+
this.abortControllers.delete(payload.sessionId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. 级联打断属于该会话的所有子智能体分支 (Sub-Agents)
|
|
104
|
+
for (const [key, childCtrl] of this.abortControllers.entries()) {
|
|
105
|
+
if (key.startsWith(`${payload.sessionId}_sub_`)) {
|
|
106
|
+
childCtrl.abort();
|
|
107
|
+
this.abortControllers.delete(key);
|
|
108
|
+
const subSessionId = key.substring(`${payload.sessionId}_sub_`.length);
|
|
109
|
+
this.sessionManager.updateSession(subSessionId, { status: 'failed', durationMs: 0 }).catch(() => { });
|
|
110
|
+
this.context.logger.warn(`[AgentService] 已级联打断子智能体会话 ${subSessionId}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 物理设计优势:
|
|
119
|
+
1. **通道完全解耦**:无论是 Web 前端点击“停止”,还是 Telegram、微信通道发来 `/stop` 命令,通道插件只需向总线触发 `eventBus.emit('session:interrupt', { sessionId })`,无需感知内部具体如何执行中断。
|
|
120
|
+
2. **级联树形中断**:不仅能打断当前主执行器,还能顺带将该会话衍生派生的所有子智能体任务全部安全熔断,彻底杜绝后台算力泄露。
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 三、 【避坑指南】防止 Abort 后网络连接僵尸残留 (TCP Leak)
|
|
126
|
+
|
|
127
|
+
在开发中,很多开发者觉得自己调用了 `controller.abort()`,Node.js 就会打点好一切。
|
|
128
|
+
|
|
129
|
+
### 1. 僵尸链接(Zombie Connections)隐患
|
|
130
|
+
如果你的大模型插件使用的是第三方厂商封装的旧版 SDK,它们虽然在表面上接受了 `signal`,但其内部的 HTTP 请求实现(例如使用了未正确适配 Web API 标准的 `axios` 或老版 `http.request`)**并没有在捕获到 abort 信号时显式调用 socket.destroy()**。
|
|
131
|
+
* **物理惨剧**:尽管前端展示了“已停止生成”,但服务器后台与大模型 API 之间的 TCP 长链接其实**依然在默默下载数据并保持保活(Keep-Alive)状态**。
|
|
132
|
+
* 高并发下,这会在操作系统底层产生大量的“僵尸连接(Zombie Connections)”,迅速耗尽操作系统的物理文件描述符限制(FD Limit),导致整个 Web 服务器无法再接受任何新连接,发生雪崩瘫痪。
|
|
133
|
+
|
|
134
|
+
### 2. 避坑策略:底座层物理强制销毁
|
|
135
|
+
底座在封装网络请求时,切不可完全信任第三方 SDK。我们必须设立一道底层的**强制销毁保底防线**:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// 💡 底层物理强制销毁防线
|
|
139
|
+
const socketTimeoutSignal = AbortSignal.timeout(90000); // 90秒强制超时信号
|
|
140
|
+
const clientSignal = options?.signal;
|
|
141
|
+
|
|
142
|
+
// 1. 利用 Node.js 22+ 的 AbortSignal.any() 缝合用户停止信号与超时信号
|
|
143
|
+
const combinedSignal = clientSignal
|
|
144
|
+
? AbortSignal.any([clientSignal, socketTimeoutSignal])
|
|
145
|
+
: socketTimeoutSignal;
|
|
146
|
+
|
|
147
|
+
const response = await fetch(requestUrl, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: JSON.stringify(requestBody),
|
|
150
|
+
signal: combinedSignal
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// 2. 如果发生了 abort,强制将底层 socket 物理打碎
|
|
154
|
+
if (combinedSignal.aborted) {
|
|
155
|
+
// 强行关闭连接,绝不残留僵尸保活
|
|
156
|
+
response.body?.cancel();
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
通过这一层 `AbortSignal.any()` 缝合信号与主动 body 取消防御,我们不仅确保了用户能瞬间刹车,更从物理网络连接层面上防范了连接泄漏,为智能体底座的长期高可用运行筑起了防波堤。
|
|
161
|
+
|
|
162
|
+
本节我们深入解剖了 AbortSignal 在智能体网络、执行器和工具三层物理控制网中的工作逻辑与防泄露设计。在下一小节中,我们将探秘 EventBus 在多通道插件(如 CLI 与 WebSockets)之间的异步事件通信流转细节。
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "9.2 异步事件通信机制"
|
|
3
|
+
weight: 20
|
|
4
|
+
description: "探讨 EventBus 在核心执行器、连接管理器与多通道插件(WSS/CLI/微信)之间的事件解耦设计,防御高并发下的事件监听器内存泄露。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 9.2 异步事件通信机制
|
|
8
|
+
|
|
9
|
+
在 9.1 节中,我们建立了基于 AbortSignal 的物理刹车机制。在完成了“思考”、“行动”与“刹车”的闭环之后,智能体底座开始面临另一个极具挑战的物理命题 —— **如何优雅地将智能体的大脑与纷繁复杂的外部物理通信渠道连接起来?**
|
|
10
|
+
|
|
11
|
+
一个工业级的智能体应用,可能需要同时服务于:
|
|
12
|
+
* **本地开发**:通过 CLI 终端命令行进行调试交互。
|
|
13
|
+
* **Web 网页**:通过 WebSockets 双向通道进行低延迟流式推送。
|
|
14
|
+
* **社交网络**:通过 Telegram、企业微信、微信公众号等 IM 渠道连入。
|
|
15
|
+
|
|
16
|
+
如果底座核心执行器 `agent-executor.ts` 直接引用这些具体的通信接口:
|
|
17
|
+
|
|
18
|
+
* **毁灭性的系统高耦合**:每增加一个新通道,就得修改底座核心代码;一旦某个通道插件因网络延迟挂起,会直接把 ReAct 主流程拖死。
|
|
19
|
+
|
|
20
|
+
本节我们将解剖 Freya 如何通过 **EventBus 事件解耦管道**,在多通道插件、连接管理器与核心执行器之间建立高内聚、零强绑定的事件流转网,并彻底防御并发下的“事件监听器内存泄露(Listener Leak)”事故。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 一、 三层事件解耦通信架构的设计
|
|
25
|
+
|
|
26
|
+
为了实现“插头式”的多通道接入,Freya 在底座中设计了**三层事件解耦管道**:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
30
|
+
│ 第一层:通道插件层 (Channels / 外围) │
|
|
31
|
+
│ 微信插件 (WeChat) TG 插件 (Telegram) WebSockets 插件 (WSS) │
|
|
32
|
+
└──────┬──────────────────────┬──────────────────────┬─────────┘
|
|
33
|
+
│ emit │ emit │ emit
|
|
34
|
+
▼ connection:message ▼ connection:message ▼ connection:message
|
|
35
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
36
|
+
│ 第二层:连接管理器层 (ConnectionManager / 中枢) │
|
|
37
|
+
└──────────────────────────────┬───────────────────────────────┘
|
|
38
|
+
│ 调度运行
|
|
39
|
+
▼
|
|
40
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
41
|
+
│ 第三层:核心执行器层 (Executor / 大脑) │
|
|
42
|
+
│ 只往 EventBus 广播 text、tool 状态事件 │
|
|
43
|
+
└──────────────────────────────────────────────────────────────┘
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 1. 第一层:通道插件层(Channels)
|
|
47
|
+
通道插件(如 `@eoasmxd/freya-plugin-wecom`)扮演着“网关插头”的角色。
|
|
48
|
+
* **职责**:只负责将外网平台发来的 Raw 微信报文翻译并包裹为统一格式的 `connection:message` 事件,向 `EventBus` 发送;同时,监听 `connection:reply` 事件。一旦收到 reply 广播,立刻调用微信 API 将消息发送回去。
|
|
49
|
+
* **优势**:插件完全不知道底座是谁、大模型是哪家、甚至不知道 Session 是怎么维护的。它只负责最本职的“网关收发”。
|
|
50
|
+
|
|
51
|
+
### 2. 第二层:连接管理器层(ConnectionManager)
|
|
52
|
+
内核的中枢协调器。
|
|
53
|
+
* **职责**:它监听 EventBus 上的 `connection:message`。一旦收到,根据 `sessionId` 调度执行器进入 ReAct `run()` 循环。
|
|
54
|
+
* 同时,它实时监听执行器 emit 出来的 `text:chunk`(大模型吐字事件),将其重组翻译后,emit 出 `connection:reply` 事件发送给对应的通道插件。
|
|
55
|
+
|
|
56
|
+
### 3. 第三层:核心执行器层(Executor)
|
|
57
|
+
智能体的心智模型。
|
|
58
|
+
* **职责**:执行器**对微信、TG 或网页 WebSockets 的存在完全一无所知**!它只专注于 ReAct 决策,并在推理过程中把产生的所有 Token、Tool Call 状态作为抽象事件广播出来:
|
|
59
|
+
`eventBus.emit('text:chunk', { sessionId, text })`。
|
|
60
|
+
|
|
61
|
+
这一套三层事件管道设计,在物理上实现了极致的解耦。底座核心代码无需改动一行,我们就可以在后台无缝地为智能体更换几十种不同的外挂连接方式。
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 二、 微信/Web 通道流式的事件流转全景时序
|
|
66
|
+
|
|
67
|
+
为了让读者清晰理解这套无强引用的事件流转美学,我们用时序表列出“用户在通道发消息 -> 智能体流式回传”的完整物理时序:
|
|
68
|
+
|
|
69
|
+
1. **用户** 在微信/Web 界面中打字:“*帮我读取 notes.txt 并总结核心要点*” 发送。
|
|
70
|
+
2. **通道插件** 接收到网络回调报文,解析为通用消息结构。
|
|
71
|
+
3. **通道插件** 向总线派发事件:`eventBus.emit('connection:message', { channelId: 'wx_01', connectionId: 'conn_101', text: '帮我读取 notes.txt 并总结核心要点' })`。
|
|
72
|
+
4. **连接管理器 (ConnectionManager)** 监听到 `connection:message`,绑定或创建对应 `sessionId`,并调度 `agentExecutor` 启动 ReAct 推理。
|
|
73
|
+
5. **核心执行器** 调用 `read_file` 工具读取工作区沙箱内的 `notes.txt`,并通过 `tool:status` 广播执行进度。
|
|
74
|
+
6. 大模型进入最终总结回答轮次,逐字流式返回文本 Chunk。
|
|
75
|
+
7. **连接管理器** 实时捕获流式分片,向总线派发增量答复事件:`eventBus.emit('connection:reply:delta', { connectionId: 'conn_101', text: '【' })`。
|
|
76
|
+
8. **通道插件** 监听到 `connection:reply:delta`,立即调用流式发送接口,把字符实时推送到用户的屏幕上。
|
|
77
|
+
9. 大模型生成完毕,连接管理器触发 `eventBus.emit('connection:reply:completed', { connectionId: 'conn_101' })` 宣告流式传输安全完结。
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 三、 【安全调试】警惕 Event Listener Leak(事件监听器内存泄露)
|
|
83
|
+
|
|
84
|
+
在这一套基于事件总线的高频交互系统中,Node.js 开发者极易踩中一个直接导致生产环境 OOM 暴毙的致命隐患 —— **事件监听器内存泄露(Listener Leak)**。
|
|
85
|
+
|
|
86
|
+
### 1. 泄露成因:断线重连下的闭包积压
|
|
87
|
+
在 Web 交互中,用户的 WebSockets 连接会因为电梯信号不好等原因高频发生“断开 -> 重连”。
|
|
88
|
+
如果你的底座代码在每次新建连接时,写出了如下逻辑:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
// ❌ 存在致命 Listener 内存泄露的代码
|
|
92
|
+
export class UnsafeConnectionHandler {
|
|
93
|
+
handleNewConnection(ws, sessionId) {
|
|
94
|
+
// 每次用户重连,都为 eventBus 挂载一个新的 chunk 监听器
|
|
95
|
+
const chunkListener = (data) => {
|
|
96
|
+
ws.send(JSON.stringify(data));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
eventBus.on('text:chunk', chunkListener); // 注册监听器 (隐式持有 ws 连接闭包)
|
|
100
|
+
|
|
101
|
+
ws.on('close', () => {
|
|
102
|
+
console.log("用户连接断开了。");
|
|
103
|
+
// ⚠️ 致命疏忽:这里漏掉了 eventBus.off() 移除监听器的操作!
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 2. 物理坠毁路径:
|
|
110
|
+
当连接断开,虽然 `ws` 连接在表面上失效了,但因为 `eventBus` 是全局单例,它内部的监听器数组里**依然死死强引用着刚才的 `chunkListener` 闭包,进而强引用着刚才的 `ws` 实例。**
|
|
111
|
+
V8 引擎的垃圾回收机制(GC)在扫描时,由于存在这条强引用依赖链,**绝对无法回收这部分内存**。
|
|
112
|
+
|
|
113
|
+
如果用户一天内断开重连了 1,000 次,内存里就会积压 1,000 个废弃连接的闭包对象。服务器的物理内存会被瞬间榨干,最终触发 Node.js 内存溢出(OOM)崩溃死机。
|
|
114
|
+
|
|
115
|
+
### 3. 避坑防线:显式注销与 once 机制
|
|
116
|
+
为了物理防范事件泄露,底座在销毁任何连接通道时,**必须执行斩断引用的善后工作**:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// ✅ 健壮安全的连接监听与清理实现
|
|
120
|
+
export class SafeConnectionHandler {
|
|
121
|
+
handleNewConnection(ws, sessionId) {
|
|
122
|
+
const chunkListener = (data) => {
|
|
123
|
+
if (data.sessionId === sessionId) {
|
|
124
|
+
ws.send(JSON.stringify(data));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// 1. 注册监听
|
|
129
|
+
eventBus.on('text:chunk', chunkListener);
|
|
130
|
+
|
|
131
|
+
// 2. 💡 物理双保险清理:当连接关闭时,强制反向解绑
|
|
132
|
+
ws.on('close', () => {
|
|
133
|
+
console.log(`[ConnectionGC] 用户会话 [${sessionId}] 断开,强制解绑 EventBus 监听器。`);
|
|
134
|
+
eventBus.off('text:chunk', chunkListener); // 物理切断强引用线,允许 GC 完美回收内存
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
通过在 `ws.on('close')` 中强制调用 `eventBus.off` 解绑监听器,我们斩断了强引用的闭包连环链,内存得以被 GC 平稳回收,确保了底座服务器在百万次重连风暴下依然长青不挂。
|
|
141
|
+
|
|
142
|
+
本节我们深入解刨了多通道事件总线的解耦通信机制与事件监听器内存泄露的成因及防守设计。在下一小节中,我们将切入 Freya 的中断流转与结算流源码,看懂系统是如何在手动停止时优雅结算 Token 并回收资源的。
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "9.3 【白盒剖析】中断流转与抢救性结算"
|
|
3
|
+
weight: 30
|
|
4
|
+
description: "白盒解剖 Freya 的 agent-service.ts 源码,拆解 AbortError 捕获、部分响应(Partial Response)钢印落盘与子智能体级联刹车算法。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 9.3 【白盒剖析】中断流转与抢救性结算
|
|
8
|
+
|
|
9
|
+
在 9.1 和 9.2 节中,我们建立了基于 AbortSignal 的物理控制网络以及 EventBus 多通道解耦通信。本节我们将实际切入 Freya 内核的核心服务(位于 `packages/core/src/agent/agent-service.ts`)源码。
|
|
10
|
+
|
|
11
|
+
我们将白盒剖析底座是如何在捕获到 `AbortError` 中断异常时,进行**半截响应文本(Partial Response)的抢救性落盘**,以及如何在父子智能体之间实现**异步级联刹车控制**的。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 一、 中断生命周期控制链
|
|
16
|
+
|
|
17
|
+
当用户发起对话时,`AgentService` 会在 `executeChat` 或 `handleRequest` 阶段,为当前 `sessionId` 实例化专属的 `AbortController` 并挂载到本地字典:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
this.abortControllers.set(message.sessionId, controller); // 注册控制器
|
|
22
|
+
|
|
23
|
+
const response = await this.agentExecutor.run(
|
|
24
|
+
message.sessionId,
|
|
25
|
+
{
|
|
26
|
+
signal: controller.signal, // 透传刹车信号
|
|
27
|
+
onChunk: (deltaText) => {
|
|
28
|
+
partialResponse += deltaText; // 实时累加已经生成的流式文本
|
|
29
|
+
this.context.eventBus.emit('session:reply:delta', { sessionId: message.sessionId, text: deltaText });
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
this.abortControllers.delete(message.sessionId); // 正常结束,移除控制器
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
在流式吐字期间,`partialResponse` 字符串像一个漏斗,实时收纳大模型吐出的每一个 Token。这为接下来的“抢救性落盘”做好了物理准备。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 二、 中断异常捕获与“部分响应”抢救性落盘 (Partial Response Saving)
|
|
42
|
+
|
|
43
|
+
当用户点击停止按钮,信号拉起,大模型网络 fetch 请求被截断,`agentExecutor.run()` 会因为网络链路强行重置而抛出 `AbortError` 异常,被外层的 `catch` 块拦截。
|
|
44
|
+
|
|
45
|
+
### 1. 新手最容易犯的“记忆丢失与计费糊涂账”大坑
|
|
46
|
+
* **痛点**:很多初学者在写 `catch(err)` 时,一旦发现是用户主动取消了,就直接什么都不做(甚至直接清空历史)。
|
|
47
|
+
* **后果**:
|
|
48
|
+
1. 大模型虽然只说了一半,但这几百个 Token 已经在云端真实消费并计费了。如果不把这一半文本存入历史,账单上的 Token 消耗将对不上,形成糊涂账。
|
|
49
|
+
2. 大模型脑子里的对话历史出现了“断层”。大模型不知道自己刚才说了什么。下一轮用户接着问时,大模型会因为缺乏半截语境产生答非所问。
|
|
50
|
+
|
|
51
|
+
### 2. Freya 的解决方案:*(已中止)* 钢印落盘
|
|
52
|
+
在 `agent-service.ts` 的 catch 块中,内核引入了极其巧妙的**部分响应抢救性落盘**:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
this.abortControllers.delete(message.sessionId); // 清理控制器缓存
|
|
57
|
+
|
|
58
|
+
// 1. 拦截 AbortError (包括用户手动中止或网络超时)
|
|
59
|
+
if (err.name === 'AbortError') {
|
|
60
|
+
this.context.logger.warn(`会话 ${message.sessionId} 因用户取消已中止生成流。`);
|
|
61
|
+
|
|
62
|
+
// 2. 💡 抢救性检查:如果此前大模型已经流式吐出了一半文本
|
|
63
|
+
if (partialResponse.trim()) {
|
|
64
|
+
// 强行将这半截子文本作为 role: "assistant" 追加存入 Session 数据库
|
|
65
|
+
// 并在末尾刻上 `*(已中止)*` 的物理钢印,通知 LLM 此处被强行切断了
|
|
66
|
+
await this.sessionManager.appendMessage(message.sessionId, {
|
|
67
|
+
role: 'assistant',
|
|
68
|
+
content: `${partialResponse}\n\n*(已中止)*`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. 正常向外广播完成事件,平稳关闭流通道,防止前台挂起转圈
|
|
73
|
+
this.context.eventBus.emit('session:reply:completed', { sessionId: message.sessionId });
|
|
74
|
+
} else {
|
|
75
|
+
// 处理其他真正内核报错
|
|
76
|
+
...
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* **物理设计优势**:
|
|
82
|
+
这不仅让已发生的计费 Token 得到了物理归档(账单与历史完美一致),更为大模型在下一轮决策中提供了极其精准的心流断点——当大模型在下一轮看到以 `*(已中止)*` 结尾的历史消息,它的自注意力层瞬间就能理解“刚才我的话被主系统掐断了”,从而优雅地在下一句中做补偿性回答,体验极其顺滑。
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 三、 父子智能体的异步级联刹车
|
|
87
|
+
|
|
88
|
+
在 Freya 的 Monorepo 微内核设计中,支持一个父智能体(如 Host Agent)拉起多个子智能体(Sub Agent)进行并发计算。
|
|
89
|
+
|
|
90
|
+
当调用 `cancelSubAgent(childSessionId)` 终止某个子智能体时,底座必须保障级联生命周期的平稳注销:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
cancelSubAgent(childSessionId: string): string {
|
|
94
|
+
let targetKey: string | null = null;
|
|
95
|
+
let controller: AbortController | null = null;
|
|
96
|
+
|
|
97
|
+
// 1. 遍历活跃控制器字典,支持以 parentSessionId_sub_childSessionId 复合键路由查找
|
|
98
|
+
for (const [key, ctrl] of this.abortControllers.entries()) {
|
|
99
|
+
if (key === childSessionId || key.endsWith(`_sub_${childSessionId}`)) {
|
|
100
|
+
targetKey = key;
|
|
101
|
+
controller = ctrl;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 2. 如果找到活跃的子控制器
|
|
107
|
+
if (controller && targetKey) {
|
|
108
|
+
controller.abort(); // 物理拉下子智能体的刹车闸
|
|
109
|
+
this.abortControllers.delete(targetKey); // 物理释放控制器引用
|
|
110
|
+
|
|
111
|
+
// 3. ⚙️ 将子会话在持久化数据库中的状态更新为 'failed',防止其无限挂起,并清理耗时
|
|
112
|
+
this.sessionManager.updateSession(childSessionId, { status: 'failed', durationMs: 0 }).catch(() => { });
|
|
113
|
+
return `ℹ️ 子智能体会话 ${childSessionId} 中止成功。`;
|
|
114
|
+
} else {
|
|
115
|
+
throw new Error(`未找到活跃的子智能体会话 ID: ${childSessionId},或它已执行结束。`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
通过这套级联 cancellation 架构,父智能体在主线程中随时可以下发刹车指令,在操作系统和数据库层面上瞬间斩断任何在后台空转的子代计算资源,消灭了任何僵尸堆栈。
|
|
121
|
+
|
|
122
|
+
本节我们白盒看清了 `AgentService` 内部处理 AbortError 时的部分文本抢救性落盘机制与父子级联刹车算法。在下一节中,我们将在本地亲自调试,解决由于“并发状态更新锁冲突”引发的中断挂起大 Bug。
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "9.4 调试与避坑指南:中断抢占死锁与句柄释放排查"
|
|
3
|
+
weight: 40
|
|
4
|
+
description: "实战调试中断与并发更新写锁冲突引发的 Pending Deadlock 会话永久卡死,设计基于 finally 强力自解锁与通道状态重置防线。"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 9.4 调试与避坑指南:中断抢占死锁与句柄释放排查
|
|
8
|
+
|
|
9
|
+
在前几节中,我们了解了链路刹车机制的 AbortSignal 控制网、多通道解耦通信时序,并白盒剖析了 `AgentService` 内部处理中止时的“抢救性落盘”与子智能体级联 cancel 逻辑。
|
|
10
|
+
|
|
11
|
+
在多用户并发、大流量高频交互的真实智能体运行环境下,手动刹车(Abort)与底座的会话排队锁(在 6.3 节解剖的 `updateLocks` 机制)相撞,会诱发一个极为隐蔽且破坏性极强的**“Pending Deadlock (会话永久卡死)”**致命生产 Bug:
|
|
12
|
+
* 用户正在进行长对话,大模型流式吐字占用了 Session 写锁。
|
|
13
|
+
* 用户突然按下“停止(Stop)”,网络被强行终止,抛出 `AbortError`。
|
|
14
|
+
* 由于异常流没有将写锁逻辑闭环释放,该 `sessionId` 的内存 Promise 队列被**永久锁定在 pending 挂起状态**。
|
|
15
|
+
* **后果**:该用户此后发送的所有新消息、所有的工具调用结果,都会在后台默默排队并无限期挂起,智能体彻底瘫痪且永远“假死”不再理人。
|
|
16
|
+
|
|
17
|
+
本节我们将实际复现并排查解决这一死锁事故,并为流式通道设计安全的复位状态防线。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 一、 中断抢占下的“Pending Deadlock”死锁成因
|
|
22
|
+
|
|
23
|
+
在 6.3 节中,我们读到 Freya 通过单链 Promise 排队来保证写入的原子性:
|
|
24
|
+
`const next = current.then(async () => { ... })`
|
|
25
|
+
|
|
26
|
+
如果我们在 `enqueueWrite` 锁的实现中,写出了如下包含隐患的结构:
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// ❌ 存在 Pending Deadlock 隐患的脆弱锁设计
|
|
30
|
+
private async enqueueWrite(sessionId: string, fn) {
|
|
31
|
+
const current = this.updateLocks.get(sessionId) ?? Promise.resolve();
|
|
32
|
+
|
|
33
|
+
const next = current.then(async () => {
|
|
34
|
+
// 💡 异步 await:如果在这里,用户手动按下了 Abort 刹车
|
|
35
|
+
// 导致这行异步任务抛出 AbortError 崩溃异常
|
|
36
|
+
return fn();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
this.updateLocks.set(sessionId, next);
|
|
40
|
+
|
|
41
|
+
const result = await next;
|
|
42
|
+
|
|
43
|
+
// ⚠️ 致命疏忽:如果在 await 期间发生了异常崩溃中断,
|
|
44
|
+
// 逻辑流直接跳转到了外层 catch,导致这行 delete 动作被直接跳过!
|
|
45
|
+
this.updateLocks.delete(sessionId);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 物理卡死路径:
|
|
51
|
+
1. **进程 A** 挂在 `updateLocks` 上执行。
|
|
52
|
+
2. 用户点击“停止”,`fn()` 内部抛出 `AbortError`。
|
|
53
|
+
3. `await next` 捕获异常,逻辑**直接跳出**,导致 `delete(sessionId)` 没执行。
|
|
54
|
+
4. 此时 `updateLocks` 中**依然残留着那个已经崩溃了的 `next` Promise 实例**。
|
|
55
|
+
5. 用户再次发送消息,新消息调用 `enqueueWrite`,读到残留的 `current = next`。由于 `next` 已经被强行中断且没有正常解绑,新消息的 `then()` 将无限期地等待一个已经死去的 Promise 释放,导致该会话此后被永久锁死在 pending 状态,彻底断网假死。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 二、 本地调试:复现 Pending Deadlock 死锁
|
|
60
|
+
|
|
61
|
+
我们在本地编写一个死锁复现脚本,模拟高频 Abort 时的锁积压状态:
|
|
62
|
+
|
|
63
|
+
### 1. 死锁复现脚本 (deadlock_chaos_test.js)
|
|
64
|
+
```javascript
|
|
65
|
+
const updateLocks = new Map();
|
|
66
|
+
|
|
67
|
+
// 模拟带有死锁隐患的排队写方法
|
|
68
|
+
async function unsafeEnqueueWrite(userId, delayMs, shouldAbort) {
|
|
69
|
+
const current = updateLocks.get(userId) ?? Promise.resolve();
|
|
70
|
+
|
|
71
|
+
const next = current.then(async () => {
|
|
72
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
73
|
+
if (shouldAbort) {
|
|
74
|
+
// 模拟用户拉起刹车闸,抛出 AbortError
|
|
75
|
+
throw new Error("AbortError: Operation canceled by user.");
|
|
76
|
+
}
|
|
77
|
+
return `Data_Saved_${Date.now()}`;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
updateLocks.set(userId, next);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = await next;
|
|
84
|
+
// ⚠️ 脆弱释放点:一旦上面抛错,这里就会被物理跳过
|
|
85
|
+
updateLocks.delete(userId);
|
|
86
|
+
return result;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.log(`[Exception Captured] 用户 ${userId} 执行期间发生异常: ${err.message}`);
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runDeadlockTest() {
|
|
94
|
+
console.log("🚀 启动锁死并发调试...");
|
|
95
|
+
|
|
96
|
+
// 1. 模拟第一笔请求:中途被用户强行 Abort 中断
|
|
97
|
+
try {
|
|
98
|
+
await unsafeEnqueueWrite("小明", 50, true);
|
|
99
|
+
} catch {
|
|
100
|
+
console.log("ℹ️ 第一笔写入已经被 Abort 掐断。");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. 模拟小明平复心情,发起第二笔常规写入请求
|
|
104
|
+
console.log("\n--- 小明试图发送第二条消息... ---");
|
|
105
|
+
|
|
106
|
+
// 设置超时探针,如果 200ms 内不返回,判定发生死锁挂起
|
|
107
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
108
|
+
setTimeout(() => reject(new Error("TIMEOUT_DEADLOCK")), 200)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await Promise.race([
|
|
113
|
+
unsafeEnqueueWrite("小明", 50, false),
|
|
114
|
+
timeoutPromise
|
|
115
|
+
]);
|
|
116
|
+
console.log("✅ 成功!第二条消息正常写入。");
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.message === "TIMEOUT_DEADLOCK") {
|
|
119
|
+
console.error("🚨🚨🚨 [死锁确证] 小明的第二条消息被无限期挂起!锁管理器瘫痪了。");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
runDeadlockTest();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. 测试输出分析
|
|
128
|
+
运行脚本,你会看到小明的第二条消息遭遇了 `TIMEOUT_DEADLOCK`,完美复现了因为 Abort 中断异常处理缺失而导致的会话“永久冻结”灾难。
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 三、 防御实战:基于 finally 的终极释放防线
|
|
133
|
+
|
|
134
|
+
为了物理封死这一 Bug,锁管理器必须引入**不可侵犯的 `finally` 块**进行安全自解锁。
|
|
135
|
+
|
|
136
|
+
无论异步 await 中发生了什么级别的崩溃、手动 Abort 或者是内存泄露,写锁指针必须被 100% 物理清理:
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// ✅ 100% 鲁棒自解锁的内存排队写实现
|
|
140
|
+
private async enqueueWrite<T>(sessionId: string, fn: (session: Session) => Promise<T>): Promise<T> {
|
|
141
|
+
const current = this.updateLocks.get(sessionId) ?? Promise.resolve();
|
|
142
|
+
const next = current.then(async () => {
|
|
143
|
+
let session: Session | null = null;
|
|
144
|
+
try {
|
|
145
|
+
session = await this.lazyLoadSession(sessionId);
|
|
146
|
+
} catch {}
|
|
147
|
+
return fn(session as any);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.updateLocks.set(sessionId, next);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return await next; // 等待任务执行
|
|
154
|
+
} finally {
|
|
155
|
+
// 💡 物理防线:必须在无条件执行的 finally 块中释放锁
|
|
156
|
+
// 确保无论发生 resolve 还是 reject 中断,当前 Promise 都会被 delete 清除!
|
|
157
|
+
if (this.updateLocks.get(sessionId) === next) {
|
|
158
|
+
this.updateLocks.delete(sessionId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
重新将这套带 `finally` 的自解锁逻辑换回调试,小明重连后的所有后继新请求全部顺利打通,死锁瞬间被化解。
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 四、 避坑经验:流式状态机通道复位(Channel Reset)机制
|
|
169
|
+
|
|
170
|
+
当大模型被 Abort 刹车后,除了底座内部解开写锁外,还有一个经典的交互 Bug:**前端页面的“输入框”一直被灰色禁用(Disabled),发送按钮不停转圈,用户无法再次打字输入。**
|
|
171
|
+
|
|
172
|
+
* **物理成因**:这是因为当底座捕获到 `AbortError` 后,通道插件(如 WebSockets 通道)由于没收到来自大模型的自然结束标记,**忘记向下游客户端推送流式完结控制帧(Stream End Frame)**。客户端的前端流式解析器判定“大模型仍在打字”,因而拒绝复位 UI 状态。
|
|
173
|
+
* **避坑手段:强制状态复位广播**:
|
|
174
|
+
底座在捕获 `AbortError` 后的 `catch` 块中,**必须强制 emit `'session:reply:completed'` 事件**,强迫所有外挂的通道插件(WebSocket、微信、TG)向下游客户端发送标准的流式完结信号(如 `[DONE]` 帧或关闭 WS 状态字节),强制复位前端的 UI 状态,解锁用户输入框:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
if (err.name === 'AbortError') {
|
|
178
|
+
// 强制向所有通道广播完成控制帧,物理复位前端状态
|
|
179
|
+
this.context.eventBus.emit('session:reply:completed', { sessionId: message.sessionId });
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
通过这一层自解锁锁机制与流式状态复位广播,我们的智能体底座在面临各种粗暴的中断、频繁重连和网关重置时,依然能表现出如同防弹衣般的稳定性和长青的高可用性。
|
|
184
|
+
|
|
185
|
+
本节我们通过 `finally` 终极自解锁与强制状态复位广播,彻底扫清了流式与事件驱动下的核心 Bug。
|
|
186
|
+
|
|
187
|
+
在下一部分中,我们将跨出“流式通信”的血液流转,进入最富工程架构挑战的微内核插件契约世界。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "第四部分:流式与事件驱动"
|
|
3
|
+
weight: 50
|
|
4
|
+
bookCollapseSection: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 第四部分:智能体的血脉与交互体验 —— 流式通信与事件驱动
|
|
8
|
+
|
|
9
|
+
本部分将深入解密智能体的网络血液系统。我们将剖析 SSE(Server-Sent Events)流式协议本质、字符分片传输中的乱序解码,以及双通道事件总线的解耦架构设计。
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 🧭 章节导学与阅读清单
|
|
14
|
+
|
|
15
|
+
### ⚡ 第 8 章:用户交互期望:流式响应 (Streaming Delta) 的传输秘密
|
|
16
|
+
探索流式 SSE 推送与长连接原理,解密如何在向用户吐出流的同时,拦截和隐藏 Thought 或 Tool 的内部推理流。
|
|
17
|
+
* 👉 **[8.1 SSE 协议本质与首字延迟革命](8.1_sse_protocol_basics.md)**
|
|
18
|
+
* 👉 **[8.2 隐藏中间推理的工程艺术](8.2_hiding_thoughts_in_stream.md)**
|
|
19
|
+
* 👉 **[8.3 【白盒剖析】EventBus 事件广播与响应推送](8.3_freya_event_bus.md)**
|
|
20
|
+
* 👉 **[8.4 调试与避坑指南:反代缓存与流式 UTF-8 字符截断排错](8.4_debugging_stream_decoder.md)**
|
|
21
|
+
|
|
22
|
+
### 🛑 第 9 章:计费解耦、状态监测与异步中断 (Abort)
|
|
23
|
+
掌握 AbortController 信号在多级异步调用中的打断链传导,分析 Token 消费审计与结算的解耦设计,以及解决打断后资源未释放导致的泄漏。
|
|
24
|
+
* 👉 **[9.1 链路级刹车机制](9.1_abort_signal_braking.md)**
|
|
25
|
+
* 👉 **[9.2 异步事件通信机制](9.2_async_event_channels.md)**
|
|
26
|
+
* 👉 **[9.3 【白盒剖析】中断流转与抢救性结算](9.3_freya_abort_billing.md)**
|
|
27
|
+
* 👉 **[9.4 调试与避坑指南:中断抢占死锁与句柄释放排查](9.4_debugging_abort_lock_deadlock.md)**
|
|
28
|
+
|