@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.
Files changed (79) hide show
  1. package/README.md +9 -3
  2. package/core/package.json +2 -2
  3. package/doc/_index.md +20 -7
  4. package/doc/getting-started.md +1 -1
  5. package/doc/installation-guide.md +2 -2
  6. package/doc/{architecture-design.md → specifications/architecture-design.md} +7 -4
  7. package/doc/{config-spec.md → specifications/config-spec.md} +1 -1
  8. package/doc/{llm-interface-params.md → specifications/llm-interface-params.md} +8 -8
  9. package/doc/{prompt-system.md → specifications/prompt-system.md} +1 -1
  10. package/doc/tutorials/_index.md +96 -0
  11. package/doc/tutorials/part0_basic/0.1_probability_prediction.md +155 -0
  12. package/doc/tutorials/part0_basic/0.2_attention_and_context.md +145 -0
  13. package/doc/tutorials/part0_basic/0.3_generation_parameters.md +132 -0
  14. package/doc/tutorials/part0_basic/0.4_debugging_token.md +99 -0
  15. package/doc/tutorials/part0_basic/1.1_stateless_and_history.md +143 -0
  16. package/doc/tutorials/part0_basic/1.2_chat_data_structure.md +179 -0
  17. package/doc/tutorials/part0_basic/1.3_system_user_assistant.md +147 -0
  18. package/doc/tutorials/part0_basic/1.4_freya_model_proxy.md +203 -0
  19. package/doc/tutorials/part0_basic/_index.md +28 -0
  20. package/doc/tutorials/part1_react/2.1_agency_vs_chatbot.md +127 -0
  21. package/doc/tutorials/part1_react/2.2_react_mind_model.md +144 -0
  22. package/doc/tutorials/part1_react/2.3_freya_agent_executor.md +233 -0
  23. package/doc/tutorials/part1_react/2.4_debugging_loop_deadlock.md +160 -0
  24. package/doc/tutorials/part1_react/3.1_hardcoded_prompt_pain.md +104 -0
  25. package/doc/tutorials/part1_react/3.2_decoupled_architecture.md +138 -0
  26. package/doc/tutorials/part1_react/3.3_freya_dual_read_probe.md +152 -0
  27. package/doc/tutorials/part1_react/3.4_debugging_composition_placeholder.md +97 -0
  28. package/doc/tutorials/part1_react/_index.md +28 -0
  29. package/doc/tutorials/part2_tools/4.1_json_schema_mapping.md +119 -0
  30. package/doc/tutorials/part2_tools/4.2_tool_call_raw_packet.md +105 -0
  31. package/doc/tutorials/part2_tools/4.3_freya_tool_execution.md +145 -0
  32. package/doc/tutorials/part2_tools/4.4_debugging_observation_fix.md +154 -0
  33. package/doc/tutorials/part2_tools/5.1_observation_injection.md +131 -0
  34. package/doc/tutorials/part2_tools/5.2_openai_vs_gemini_protocol.md +125 -0
  35. package/doc/tutorials/part2_tools/5.3_freya_llm_proxy_mapping.md +162 -0
  36. package/doc/tutorials/part2_tools/5.4_debugging_parallel_call_chaos.md +135 -0
  37. package/doc/tutorials/part2_tools/_index.md +28 -0
  38. package/doc/tutorials/part3_memory/6.1_session_state_lifecycle.md +143 -0
  39. package/doc/tutorials/part3_memory/6.2_physical_sandbox_separation.md +108 -0
  40. package/doc/tutorials/part3_memory/6.3_freya_session_storage.md +139 -0
  41. package/doc/tutorials/part3_memory/6.4_debugging_session_concurrency.md +161 -0
  42. package/doc/tutorials/part3_memory/7.1_context_overflow_loss.md +109 -0
  43. package/doc/tutorials/part3_memory/7.2_sliding_window_vs_summary.md +85 -0
  44. package/doc/tutorials/part3_memory/7.3_freya_compactor_impl.md +142 -0
  45. package/doc/tutorials/part3_memory/7.4_debugging_summarize_deadlock.md +160 -0
  46. package/doc/tutorials/part3_memory/_index.md +28 -0
  47. package/doc/tutorials/part4_streaming/8.1_sse_protocol_basics.md +119 -0
  48. package/doc/tutorials/part4_streaming/8.2_hiding_thoughts_in_stream.md +132 -0
  49. package/doc/tutorials/part4_streaming/8.3_freya_event_bus.md +104 -0
  50. package/doc/tutorials/part4_streaming/8.4_debugging_stream_decoder.md +158 -0
  51. package/doc/tutorials/part4_streaming/9.1_abort_signal_braking.md +162 -0
  52. package/doc/tutorials/part4_streaming/9.2_async_event_channels.md +142 -0
  53. package/doc/tutorials/part4_streaming/9.3_freya_abort_billing.md +122 -0
  54. package/doc/tutorials/part4_streaming/9.4_debugging_abort_lock_deadlock.md +187 -0
  55. package/doc/tutorials/part4_streaming/_index.md +28 -0
  56. package/doc/tutorials/part5_plugins/10.1_microkernel_decoupling.md +142 -0
  57. package/doc/tutorials/part5_plugins/10.2_plugin_metadata_security.md +125 -0
  58. package/doc/tutorials/part5_plugins/10.3_channel_plugin_development.md +149 -0
  59. package/doc/tutorials/part5_plugins/10.4_debugging_channel_reconnection.md +160 -0
  60. package/doc/tutorials/part5_plugins/_index.md +21 -0
  61. package/doc/tutorials/part6_advanced/11.1_react_model_flaws.md +111 -0
  62. package/doc/tutorials/part6_advanced/11.2_reflexion_mind_model.md +103 -0
  63. package/doc/tutorials/part6_advanced/11.3_reflexion_hands_on.md +182 -0
  64. package/doc/tutorials/part6_advanced/11.4_debugging_reflexion_convergence.md +108 -0
  65. package/doc/tutorials/part6_advanced/12.1_single_agent_limits.md +100 -0
  66. package/doc/tutorials/part6_advanced/12.2_multi_agent_patterns.md +121 -0
  67. package/doc/tutorials/part6_advanced/12.3_freya_multi_agent_routing.md +143 -0
  68. package/doc/tutorials/part6_advanced/12.4_multi_agent_hands_on.md +176 -0
  69. package/doc/tutorials/part6_advanced/_index.md +28 -0
  70. package/doc/tutorials/preface.md +30 -0
  71. package/package.json +2 -2
  72. package/plugins/plugin-gemini/package.json +1 -1
  73. package/plugins/plugin-openai/package.json +1 -1
  74. package/plugins/plugin-telegram-channel/package.json +1 -1
  75. package/plugins/plugin-tool-fs/package.json +1 -1
  76. package/plugins/plugin-tool-memory/package.json +1 -1
  77. package/plugins/plugin-tool-web/package.json +1 -1
  78. package/plugins/plugin-wecom-channel/package.json +1 -1
  79. package/plugins/plugin-weixin-channel/package.json +1 -1
@@ -0,0 +1,233 @@
1
+ ---
2
+ title: "2.3 【白盒剖析】决策循环与 agent-executor 源码"
3
+ weight: 30
4
+ description: "白盒解剖 Freya 的 ReAct 执行器 agent-executor.ts,深入 while(loop) 受控自循环与工具箱动态闲置淘汰算法。"
5
+ ---
6
+
7
+ # 2.3 【白盒剖析】决策循环与 agent-executor 源码
8
+
9
+ 在 2.2 节中,我们从原理上推演了 ReAct 的 Thought-Action-Observation 决策环。本节我们将实际推开 Freya 源码沙箱的大门,去解剖整个智能体运转的核心控制流组件 —— **ReAct 执行器**(位于 `packages/core/src/agent/agent-executor.ts`)。
10
+
11
+ 我们将毫无保留地展示它是如何通过一个物理的 `while(loop)` 受控自循环,并发调度本地工具、广播异步事件,以及如何通过主动评估淘汰机制来守护上下文空间的。
12
+
13
+ ---
14
+
15
+ ## 一、 核心解耦:FreyaAgentExecutor 的成员结构
16
+
17
+ 在 `packages/core/src/agent/agent-executor.ts` 中,`FreyaAgentExecutor` 并不直接依赖具体的网络请求库。它通过依赖注入,持有了底座的核心配置上下文和各大注册表:
18
+
19
+ ```typescript
20
+ export class FreyaAgentExecutor {
21
+ private llmPlugin: any;
22
+
23
+ constructor(
24
+ private context: FreyaContext,
25
+ private promptRegistry: FreyaPromptRegistry,
26
+ private sessionManager: FreyaSessionManager,
27
+ private toolRegistry: FreyaToolRegistry,
28
+ private skillRegistry: FreyaSkillRegistry
29
+ ) {
30
+ this.llmPlugin = context.llm; // 指向统一模型代理层 llm-proxy.ts
31
+ }
32
+ }
33
+ ```
34
+
35
+ 这种结构实现了完美的**控制流与服务层解耦**。
36
+
37
+ ---
38
+
39
+ ## 二、 物理 while(loop) 受控自循环剖析
40
+
41
+ 当上层应用调用 `run(sessionId, options)` 时,整个 Agent 开启了它在后台的自主探索之旅。让我们阅读 `run()` 方法中最为核心的 `while` 循环体:
42
+
43
+ ```typescript
44
+ let loop = true;
45
+ let turnCount = 0;
46
+ let lastLlmMessage: LLMMessage | null = null;
47
+ let systemPrompt = '';
48
+
49
+ while (loop) {
50
+ // 1. 读取当前的会话状态与工具箱激活状态
51
+ const session = await this.sessionManager.getOrCreate(sessionId);
52
+ const tools = this.toolRegistry.getFilteredTools(session.activeToolboxIds || []);
53
+ const history = await this.sessionManager.getHistory(sessionId);
54
+
55
+ // 2. 物理打断校验:如果用户触发了 Abort Controller,立即强行切断退出
56
+ if (signal?.aborted) {
57
+ throw new Error('对话运行已被用户主动中断。');
58
+ }
59
+
60
+ // 3. 动态织造最新的系统提示词 (Prompt Composer)
61
+ const activeSkill = skills.find((s) => s.id === session.activeSkillId);
62
+ systemPrompt = this.promptRegistry.composeSystemPrompt(activeSkill, toolInstructions, skills);
63
+
64
+ // 4. 步数防爆线防御:如果大模型陷入死循环,强制终止
65
+ const maxTurns = options?.maxTurns ?? 20;
66
+ if (turnCount++ >= maxTurns) {
67
+ this.context.logger.warn(`会话 ${sessionId} 超出最大迭代决策轮数 (${maxTurns}),正在生成最终总结...`);
68
+
69
+ // 强行灌入系统警告,勒令大模型立刻结束接龙并输出总结
70
+ const finalPayload: LLMMessage[] = [
71
+ { role: 'system', content: systemPrompt },
72
+ ...history,
73
+ {
74
+ role: 'system',
75
+ content: this.promptRegistry.get('core.prompt.max_turns') || '已达到最大决策轮数上限,请基于已完成的工作向用户总结当前进展。'
76
+ }
77
+ ];
78
+ const finalResponse = await this.llmPlugin.chat(finalPayload, undefined, { ...options });
79
+ lastLlmMessage = finalResponse.message;
80
+ break;
81
+ }
82
+
83
+ // 5. 组装输入载荷并挂载工具描述定义 (JSON Schema)
84
+ const activePayload: LLMMessage[] = [
85
+ { role: 'system', content: systemPrompt },
86
+ ...history
87
+ ];
88
+ const toolDefinitions = Array.from(tools.values()).map((t) => t.getDefinition());
89
+
90
+ // 6. 调用模型代理层
91
+ const response = await this.llmPlugin.chat(activePayload, toolDefinitions, { ...options });
92
+ const replyMessage = response.message;
93
+ lastLlmMessage = replyMessage;
94
+
95
+ // 7. 判断模型响应中是否触发了 Action
96
+ if (replyMessage.toolCalls && replyMessage.toolCalls.length > 0) {
97
+ for (const tc of replyMessage.toolCalls) {
98
+ executedToolNames.add(tc.name);
99
+ }
100
+ // 先把包含 toolCalls 意图的消息存入历史 (符合 OpenAI 协议交替格式)
101
+ await this.sessionManager.appendMessage(sessionId, replyMessage);
102
+
103
+ // 并发执行所有工具调用 (并发调度)
104
+ const toolPromises = replyMessage.toolCalls.map(async (toolCall: any) => {
105
+ const tool = tools.get(toolCall.name);
106
+ if (!tool) {
107
+ return {
108
+ role: 'tool' as const,
109
+ content: `Error: Tool "${toolCall.name}" not found.`,
110
+ toolCallId: toolCall.id,
111
+ toolName: toolCall.name
112
+ };
113
+ }
114
+
115
+ let args: any;
116
+ try {
117
+ args = JSON.parse(toolCall.arguments);
118
+ } catch (parseErr: any) {
119
+ this.context.logger.error(`[FreyaAgentExecutor] 解析工具参数失败: ${toolCall.arguments}`, parseErr);
120
+ this.context.eventBus.emit('tool:status', {
121
+ sessionId,
122
+ toolCallId: toolCall.id,
123
+ toolName: toolCall.name,
124
+ status: 'failed',
125
+ arguments: {},
126
+ result: `JSON 解析失败: ${parseErr.message}`
127
+ });
128
+ return {
129
+ role: 'tool' as const,
130
+ content: `Error during JSON parsing: ${parseErr.message}`,
131
+ toolCallId: toolCall.id,
132
+ toolName: toolCall.name
133
+ };
134
+ }
135
+
136
+ args.__sessionId = sessionId;
137
+ // 广播工具运行状态
138
+ this.context.eventBus.emit('tool:status', { sessionId, toolCallId: toolCall.id, toolName: toolCall.name, status: 'running', arguments: args });
139
+
140
+ try {
141
+ // 执行内核中的真实本地工具代码
142
+ const result = await tool.execute(args, this.context);
143
+ this.context.eventBus.emit('tool:status', { sessionId, toolCallId: toolCall.id, toolName: toolCall.name, status: 'completed', arguments: args, result });
144
+ return {
145
+ role: 'tool' as const,
146
+ content: result,
147
+ toolCallId: toolCall.id,
148
+ toolName: toolCall.name
149
+ };
150
+ } catch (err: any) {
151
+ this.context.eventBus.emit('tool:status', { sessionId, toolCallId: toolCall.id, toolName: toolCall.name, status: 'failed', arguments: args, result: err.message });
152
+ // 错误反馈回流:把物理执行报错灌回 LLM 的 Observation,引导自我修正
153
+ return {
154
+ role: 'tool' as const,
155
+ content: `Error during execution: ${err.message}`,
156
+ toolCallId: toolCall.id,
157
+ toolName: toolCall.name
158
+ };
159
+ }
160
+ });
161
+
162
+ const toolResults = await Promise.all(toolPromises);
163
+ // 一次性将本轮所有工具执行结果 (Observation) 追加进会话历史
164
+ await this.sessionManager.appendMessages(sessionId, toolResults);
165
+ } else {
166
+ // LLM 没有调用工具,自循环顺利终结
167
+ loop = false;
168
+ }
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## 三、 主动垃圾回收:闲置工具箱动态淘汰算法
175
+
176
+ 如果一个智能体挂载了 10 个工具箱(每个工具箱包含 5 个 Tool),如果把所有 50 个工具的 JSON Schema 都拼在 System Prompt 里面,哪怕不调用任何工具,大模型每轮接龙都需要白白支付 5000+ Token 的输入账单,直接把 Context Window 压垮。
177
+
178
+ 为了解决这一痛点,Freya 执行器在 `run()` 的结尾引入了极其精妙的**主动垃圾回收机制** —— `evaluateAndDeactivateIdleToolboxes`。
179
+
180
+ ```typescript
181
+ private async evaluateAndDeactivateIdleToolboxes(
182
+ sessionId: string,
183
+ executedToolNames: Set<string>
184
+ ): Promise<void> {
185
+ const session = await this.sessionManager.getOrCreate(sessionId);
186
+ const activeToolboxIds = session.activeToolboxIds || [];
187
+ if (activeToolboxIds.length === 0) return;
188
+
189
+ const config = this.context.config.contextManagement || {};
190
+ const threshold = config.toolboxIdleTimeoutRounds ?? 10; // 默认连续闲置 10 轮淘汰
191
+ const toolboxIdleRounds = session.toolboxIdleRounds || {};
192
+
193
+ const nextActiveToolboxIds: string[] = [];
194
+ const nextToolboxIdleRounds: Record<string, number> = {};
195
+ const deactivatedIds: string[] = [];
196
+
197
+ for (const id of activeToolboxIds) {
198
+ const boxTools = this.toolRegistry.getToolsInBox(id);
199
+ // 检查这个工具箱里的所有工具,在这一轮迭代中是否被大模型使用过
200
+ const isUsed = boxTools.some((tool) => executedToolNames.has(tool.getDefinition().name));
201
+
202
+ if (isUsed) {
203
+ nextToolboxIdleRounds[id] = 0; // 重置闲置计数
204
+ nextActiveToolboxIds.push(id);
205
+ } else {
206
+ const idleCount = (toolboxIdleRounds[id] ?? 0) + 1;
207
+ if (idleCount >= threshold) {
208
+ deactivatedIds.push(id); // 闲置超标,进入卸载名单
209
+ this.context.logger.info(`[FreyaAgentExecutor] 工具箱 [${id}] 连续闲置达到 ${idleCount} 轮,已自动执行卸载。`);
210
+ } else {
211
+ nextToolboxIdleRounds[id] = idleCount;
212
+ nextActiveToolboxIds.push(id);
213
+ }
214
+ }
215
+ }
216
+
217
+ // 如果有工具箱被淘汰,更新 Session,动态释放后续步骤的上下文空间;否则仅保存最新的闲置计数
218
+ if (deactivatedIds.length > 0) {
219
+ await this.sessionManager.updateSession(sessionId, {
220
+ activeToolboxIds: nextActiveToolboxIds,
221
+ toolboxIdleRounds: nextToolboxIdleRounds
222
+ });
223
+ } else {
224
+ await this.sessionManager.updateSession(sessionId, {
225
+ toolboxIdleRounds: nextToolboxIdleRounds
226
+ });
227
+ }
228
+ }
229
+ ```
230
+
231
+ 通过这一淘汰算法,处于闲置状态的工具箱会被内核动态卸载。这既降低了 Token 费用开销,又大大清空了大模型的注意力空间,防止其被无关工具定义干扰发生判断偏差。
232
+
233
+ 通过本节的源码剖析,我们得以从物理实现上看到了 ReAct 环的完整骨骼。在下一节中,我们将在本地亲自复现并解决智能体开发中最让人头疼的问题 —— “决策死循环死锁”。
@@ -0,0 +1,160 @@
1
+ ---
2
+ title: "2.4 调试与避坑指南:ReAct 自循环失控熔断"
3
+ weight: 40
4
+ description: "本地动手复现 ReAct 智能体“鬼打墙”失控现象,设计内核级最大迭代限制防爆网与超时资源释放机制。"
5
+ ---
6
+
7
+ # 2.4 调试与避坑指南:ReAct 自循环失控熔断
8
+
9
+ 在前几节中,我们了解了 ReAct 的心智模型,并白盒解剖了 Freya 内核的自循环执行器 `packages/core/src/agent/agent-executor.ts`。在物理世界中,只要是循环,就限制不住因逻辑死角导致的失控可能,这就是智能体设计上的终极威胁 —— **失控闭环(Loop Deadlock)**。
10
+
11
+ 在智能体开发领域,这种失控闭环有一个生动的名字,叫 **“鬼打墙”**。一旦 Agent 发生鬼打墙,它会在后台以迅雷不及掩耳之势连续疯狂调用大模型和外部工具,仅需几分钟就能烧光上千元人民币的 Token 费用,并彻底卡死服务器进程。
12
+
13
+ 本节我们将通过本地动手实验,亲自复现一次 ReAct “鬼打墙”,并一步步为智能体内核拉起坚实的“安全防护网”。
14
+
15
+ ---
16
+
17
+ ## 一、 智能体“鬼打墙”的物理成因
18
+
19
+ 为什么大模型会在 ReAct 循环里卡死?从自回归文字接龙和拼接历史的物理机制出发,这通常是由于以下链条发生的:
20
+
21
+ 1. **工具抛错或格式返回异常**:大模型调用了工具 A,但工具 A 因为网络抖动或参数校验失败,返回了一条 `Error` 状态的 Observation。
22
+ 2. **大模型“注意力涣散”或逻辑死角**:大模型阅读到这条失败的 Observation,因为缺乏足够的常识去改变策略,或者其注意力权重被前面的“历史剧本”强行带偏。
23
+ 3. **概率概率坍塌**:在自回归的下一步预测中,大模型认为“为了达成目标,我依然只能调用工具 A”的生成概率依然最高(例如达到了 90%)。
24
+ 4. **循环往复**:大模型再次输出调用 A,内核再次代为执行并带回报错,大模型重新接龙再次输出 A……一个完美的、贪婪消耗 Token 费用的失控物理闭环就此诞生。
25
+
26
+ ```
27
+ [LLM 思考] ──> 输出 Action: 调用A ──> [内核执行] ──> 返回 Observation: 报错 ──┐
28
+ ▲ │
29
+ └───────────────────(拼接报错历史重新灌入)───────────────────────────┘
30
+ ```
31
+
32
+ ---
33
+
34
+ ## 二、 本地动手实验:复现“鬼打墙”
35
+
36
+ 为了看清这一现象,我们在本地设计一个故意报错的“死锁工具”,并编写一个简易的 ReAct 执行器进行复现。
37
+
38
+ ### 1. 模拟死锁工具与简易执行器 (deadlock_test.js)
39
+ ```javascript
40
+ // 模拟一个永远超时的数据库查询工具
41
+ const tools = {
42
+ query_database: async (args) => {
43
+ // 故意抛出物理网络超时异常
44
+ throw new Error("Connection timeout to Database host: 192.168.1.105");
45
+ }
46
+ };
47
+
48
+ // 模拟大模型的 ReAct 接龙决策 (简化版概率输出)
49
+ function mockLLMDecision(history) {
50
+ const lastMsg = history[history.length - 1];
51
+
52
+ // 如果上一次的观察是报错,但 LLM 逻辑陷入死角,依然决定调用
53
+ if (lastMsg && lastMsg.role === "tool" && lastMsg.content.includes("timeout")) {
54
+ console.log("🤖 [LLM]: 噢,数据库连接超时了。根据接龙惯性,我决定重试一次。");
55
+ return {
56
+ toolCalls: [{ id: "call_retry_" + Date.now(), name: "query_database", arguments: "{}" }]
57
+ };
58
+ }
59
+
60
+ // 初始轮次发起调用
61
+ return {
62
+ toolCalls: [{ id: "call_init", name: "query_database", arguments: "{}" }]
63
+ };
64
+ }
65
+
66
+ // 模拟没有安全防线下的 ReAct while 循环
67
+ async function runUnsafeAgent() {
68
+ const history = [];
69
+ let loop = true;
70
+ let rounds = 0;
71
+
72
+ console.log("🚀 开始运行未设防的智能体...");
73
+
74
+ while (loop) {
75
+ rounds++;
76
+ console.log(`\n--- 第 ${rounds} 轮循环 ---`);
77
+
78
+ // 1. 大模型做决策
79
+ const decision = mockLLMDecision(history);
80
+
81
+ if (decision.toolCalls) {
82
+ console.log(`🤖 [LLM Action]: 请求调用工具 ${decision.toolCalls[0].name}`);
83
+ history.push({ role: "assistant", toolCalls: decision.toolCalls });
84
+
85
+ // 2. 内核执行工具并带回报错
86
+ const toolCall = decision.toolCalls[0];
87
+ try {
88
+ const result = await tools[toolCall.name](toolCall.arguments);
89
+ history.push({ role: "tool", content: result, toolCallId: toolCall.id });
90
+ } catch (err) {
91
+ console.log(`⚙️ [内核 Observation]: 执行工具失败 -> ${err.message}`);
92
+ // 报错回流拼接
93
+ history.push({ role: "tool", content: `Error: ${err.message}`, toolCallId: toolCall.id });
94
+ }
95
+ } else {
96
+ loop = false;
97
+ }
98
+
99
+ // 💡 物理警示:故意不设 maxTurns,模拟挂机状态
100
+ if (rounds >= 5) {
101
+ console.log("\n⚠️ [警告] 已经检测到 5 次往返,如果是在真实环境,你的 API 余额可能正在迅速清零!");
102
+ break;
103
+ }
104
+
105
+ // 增加一点微弱延时以便观察
106
+ await new Promise(r => setTimeout(r, 1000));
107
+ }
108
+ }
109
+ runUnsafeAgent();
110
+ ```
111
+
112
+ 运行上述代码,你会在控制台看到,智能体在面对“数据库超时”时,毫无主见地陷入了失控循环重试。如果没有外部限制,这个自循环将无休止地运行下去。
113
+
114
+ ---
115
+
116
+ ## 三、 内核级物理防爆网设计
117
+
118
+ 针对上述“鬼打墙”现象,在 Freya 沙箱的 `packages/core/src/agent/agent-executor.ts` 中,我们部署了极其坚实的防爆防线:
119
+
120
+ ### 迭代步数强制熔断计数器 (maxTurns)
121
+ 正如我们在 2.3 源码剖析中看到的,内核在进入 `while(loop)` 前会初始化 `turnCount = 0`。每次迭代执行一次 `turnCount++`。
122
+ * 一旦 `turnCount >= maxTurns`(默认限制为 20,生产环境通常建议限制为 8~10)。
123
+ * 内核会强行介入,**剥夺大模型继续调用工具的权利(不再传入 toolDefinitions)**。
124
+ * 向 Message 历史的最尾部强行塞入一条 **System 级别的最高警戒提示词**:
125
+
126
+ ```typescript
127
+ const finalPayload: LLMMessage[] = [
128
+ { role: 'system', content: systemPrompt },
129
+ ...history,
130
+ {
131
+ role: 'system',
132
+ // 从 prompt 注册表动态加载 core.prompt.max_turns 提示词
133
+ content: this.promptRegistry.get('core.prompt.max_turns') || '已达到最大决策轮数上限,请基于已完成的工作向用户总结当前进展。'
134
+ }
135
+ ];
136
+ ```
137
+
138
+ 大模型收到这条强插的 System 警告后,在注意力计算中会受到强烈刺激,被迫跳出“Action 概率死角”,接龙输出最终的 `Final Answer` 总结,自循环安全终结。
139
+
140
+ ---
141
+
142
+ ## 四、 【避坑经验】得体的失败:兜底总结提示词配置
143
+
144
+ 许多人在配置 `core.prompt.max_turns` 的兜底提示词时,喜欢简单地写一句:“*系统已达上限,请报错。*”
145
+
146
+ 这会导致非常差的用户体验。大模型会直接向终端用户输出一堆凌乱的底层报错堆栈(如 `Connection timeout to 192.168.1.105`),普通用户根本看不懂。
147
+
148
+ ### 🌟 优雅的架构实践
149
+ 在物理提示词注册表中,我们应当这样配置 `core.prompt.max_turns`:
150
+
151
+ ```markdown
152
+ # 标识:core.prompt.max_turns
153
+ 由于计算资源限制,本轮任务已达到最大决策轮数上限。
154
+ 请不要再尝试调用任何工具。请仔细阅读前面的所有执行历史,以得体、礼貌的语气向用户总结当前已经达成的进展,并明确告知哪些步骤失败了、下一步需要人类提供什么协助。
155
+ 例如:“我已经成功为您查询到了小明的信息,但在尝试向其发送邮件时遭遇了网络超时。目前任务暂时中断,请检查网络后重试。”
156
+ ```
157
+
158
+ 这样,即使 Agent 逻辑碰壁发生了物理熔断,**在终端用户眼里,它依然表现出了高度的智能性与极佳的服务礼仪**。
159
+
160
+ 通过本节的死锁复现与防爆网建设,我们已经将 ReAct 决策环彻底驯服。在接下来的第 3 章中,我们将把目光投向智能体的世界观 —— 提示词,去探秘系统提示词在内核内存中动态组装编织的物理过程。
@@ -0,0 +1,104 @@
1
+ ---
2
+ title: "3.1 提示词工程的痛点"
3
+ weight: 10
4
+ description: "揭密代码硬编码提示词的四大工程灾难,阐述‘代码控制流与提示词配置文件’的解耦哲学。"
5
+ ---
6
+
7
+ # 3.1 提示词工程的痛点
8
+
9
+ 在构建智能体(Agent)时,我们几乎每天都要和 **System Prompt(系统提示词)** 打交道。它是智能体的“世界观”、“道德约束”和“行动指南”。
10
+
11
+ 然而,在翻看很多开源项目或初学者的代码时,我们经常会看到如下的代码片段:
12
+
13
+ ```typescript
14
+ // ❌ 灾难性的硬编码示范
15
+ const systemPrompt = `你是一个有用的客服助理。
16
+ 你的名字叫小花。当用户问你关于退款的问题时,你必须调用 ${refundTool.name} 工具...`;
17
+ ```
18
+
19
+ 这种直接在 TypeScript 或 Python 源码里嵌入成百上千行中文自然语言提示词的做法,在业内被称为 **硬编码提示词(Hardcoded Prompts)**。
20
+
21
+ 在玩具级的 Demo 中它确实能跑通,但在正规的商业化 Agent 内核研发中,硬编码提示词无异于埋下一颗随时会引爆的“工程炸弹”。
22
+
23
+ ---
24
+
25
+ ## 一、 硬编码提示词的四大工程灾难
26
+
27
+ 将自然语言提示词混杂在代码逻辑中,会引发以下四个灾难性的后果:
28
+
29
+ ### 1. 编译部署的“高耦合低效能”
30
+ Prompt 是一门极其依赖**微调与试错**的技艺。为了防止大模型幻觉,你可能需要高频地将 Prompt 中的“绝对不要”改为“严禁”。
31
+ * 如果提示词硬编码在源码中,每一次微小的字眼调整,都必须经过代码提交(Git Commit)、走 CI/CD 流水线、重新编译打包,然后再部署上线。
32
+ * 这种将“静态控制代码”与“高频调整的自然语言”死死捆绑在一起的做法,会使提示词的调试周期从以秒为单位,被拉长到以小时甚至天为单位。
33
+
34
+ ### 2. 国际化与多语言适配(I18n)的彻底破产
35
+ 如果你的 Agent 内核将来需要同时服务中、英、日三种语言的用户:
36
+ * 当提示词是硬编码时,你将被迫在代码里写出大量的 `if-else` 分支判断来下发不同的硬编码模板。
37
+ * 这不仅让核心控制流文件(如执行器)充斥了大量的多语言文本噪音,更让非开发人员(如专业的翻译人员或 Prompt 工程师)无法参与到翻译和调优中。
38
+
39
+ ### 3. 动态配置与热更新(Hot Update)阻断
40
+ 在生产环境中,你可能需要根据不同的业务部门或用户等级,动态微调 Agent 的行为人设。如果 Prompt 固化在代码包里,系统将完全失去在不重启服务的情况下进行在线“热更新”与动态配置的弹性。
41
+
42
+ ### 4. 彻底污染控制流文件的可读性
43
+ 一个完善的商业级 System Prompt(包含世界观、工具 Schema 引导、思考链引导、兜底输出格式)通常在 **3,000 ~ 5,000 字**左右。
44
+ 如果将其直接嵌入到 TS 文件中,核心的执行控制器逻辑(本应只有几百行)会被长篇大论的自然语言完全淹没,开发人员在阅读和维护控制流代码时将极其痛苦。
45
+
46
+ ---
47
+
48
+ ## 二、 动态组装:System Prompt 的多源编织特性
49
+
50
+ 许多人以为 System Prompt 只是一个静态的 Markdown 文件,启动时直接读入内存就行了。但这依然不够,因为智能体在运行时的系统提示词是**具有高度动态性**的。
51
+
52
+ 在 Freya 沙箱内核中,每次 ReAct 迭代开始前,系统提示词都需要经历一次**多源动态编织(Dynamic Prompt Composition)**:
53
+
54
+ ```
55
+ ┌──────────────────────┐
56
+ │ Skill Prompt 技能卡 │
57
+ └──────────┬───────────┘
58
+
59
+ ▼ (合并织造)
60
+ ┌──────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
61
+ │ Tool Instructions├─>│ Prompt Composer │<─├─ 环境变量/系统时间 │
62
+ │ 工具 Schema │ │ 提示词合成器 │ │ (Context) │
63
+ └──────────────────┘ └──────────┬───────────┘ └────────────────────┘
64
+
65
+
66
+ 最终合成的 System Prompt
67
+ ```
68
+
69
+ 1. **技能卡注入(Skill Injection)**:Agent 当前可能处于“写文案”技能下,也可能处于“查数据库”技能下。内核需要根据当前激活的技能卡 Markdown,动态载入其行为守则。
70
+ 2. **工具上下文挂载(Tool Mount)**:根据当前 Session 授权的工具列表,合成器必须动态抓取这组工具的 JSON Schema 定义,在 Prompt 中声明“当前可用工具如下:...”。
71
+ 3. **动态环境变量插值**:合成器需要在运行时,自动把当前的系统时间、当前登录的用户 ID、Session 状态动态插入到模板的占位符中(如 `{{current_time}}`)。
72
+
73
+ 如果提示词被硬编码在代码里,这种多维度的动态组装将变得极其混乱且难以编写。
74
+
75
+ ---
76
+
77
+ ## 三、 “代码是控制流,Prompt 是世界观”的解耦哲学
78
+
79
+ 为了解决上述工程痛点,现代智能体系统设计必须坚守一个核心解耦哲学:
80
+
81
+ $$\text{Agent 智能体} = \text{Deterministic Code (确定性控制流)} \times \text{Dynamic Prompts (动态配置世界观)}$$
82
+
83
+ * **代码(控制流)**:负责物理执行。比如 while 循环的执行、EventBus 的分发、HTTP 响应的流式重组、数据库的连接。它是由软件工程师开发,要求具备 100% 的**确定性**。
84
+ * **Prompt(世界观)**:负责心智决策。比如扮演什么人设、如何理解工具结果、如何规划步骤。它是动态的配置资产,要求具备**极强的灵活性与可配置性**。
85
+
86
+ **代码和 Prompt 拥有完全不同的生命周期与修改频次**。因此,内核的架构设计必须将两者实施物理分离。代码必须保持极其洁净,不包含任何中文提示词;所有的提示词必须以物理文件(如 Markdown、JSON)的形式存储在外部配置目录中,通过统一的注册表进行生命周期控制。
87
+
88
+ ---
89
+
90
+ ## 四、【调试与避坑】首尾空字符与空行引发的注意力偏差
91
+
92
+ 在将外部 Markdown 提示词模板读取并编织进内存时,有一个非常隐蔽、但会对推理产生微妙物理影响的 Bug:**文件首尾多余的空白字符与空行噪音**。
93
+
94
+ ### 1. 首尾多余空白与换行对 Attention 的负面影响
95
+ 当你从外部读取 Markdown 模板并直接拼接到提示词中时,文件首尾如果携带了多余的空格、制表符或空行,会带来以下隐患:
96
+ * **Token 浪费与稀释**:首尾的空字符会被分词器(Tokenizer)识别为无意义的空白 Token,白白占用宝贵的上下文窗口。
97
+ * **注意力(Attention)发生偏移**:自回归模型的位置编码对文本起始和结束位置极为敏感。多余的首尾空行会稀疏注意力矩阵的计算权重,尤其会对置于开头的 System 指令敏感度产生轻微干扰,导致模型决策产生偏差。
98
+
99
+ ### 2. 调试防范:Prompt 首尾清理
100
+ 在 Freya 内核的提示词载入方法中,我们通过在注册时调用字符串的 `.trim()` 方法,物理剔除了 Markdown 文件首尾可能包含的多余换行符与空格噪音。
101
+
102
+ 通过这一层基础的空白清理,送入大模型的所有 Prompt 将呈现出更干净的 Token 分布,从而防止模型在计算位置编码时由于头部多余换行符发生注意力偏移。
103
+
104
+ 本节我们深入探讨了提示词硬编码的危害和解耦哲学。在下一节中,我们将详细了解这一解耦哲学在 Freya 内核中的具体落地方案 —— 零硬编码解耦架构。
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: "3.2 零硬编码解耦架构"
3
+ weight: 20
4
+ description: "设计零硬编码的系统提示词物理分离树,解析内存热更新机制与高并发下的原子替换防御策略。"
5
+ ---
6
+
7
+ # 3.2 零硬编码解耦架构
8
+
9
+ 在 3.1 节中,我们确立了将“控制代码”与“提示词配置”彻底分离的解耦哲学。本节我们将探讨如何在物理上落地这一哲学,建立起一套坚固的**零硬编码解耦架构**。
10
+
11
+ 我们将具体规定提示词文件的目录命名规范,解析运行时内存**热更新(Hot Update)**的原理,并针对高并发场景下文件更新可能引发的“脏读”事故提供内核级的原子替换防御策略。
12
+
13
+ ---
14
+
15
+ ## 一、 零硬编码的物理目录树规范
16
+
17
+ 在零硬编码架构中,所有的自然语言提示词、约束指令、总结模板全部下沉为物理的 Markdown(`.md`)文件。
18
+
19
+ 在 Freya 沙箱内核中,包内默认的提示词文件树被严格规划在 `packages/core/config/prompts/` 目录下,与 TypeScript 源码实现物理隔离:
20
+
21
+ ```
22
+ packages/core/
23
+ ├── config/
24
+ │ └── prompts/ # 系统默认核心提示词目录 (只读)
25
+ │ ├── core.prompt.identity.md # 智能体人设与本体声明
26
+ │ ├── core.prompt.max_turns.md # 达到迭代步数上限时的总结兜底模版
27
+ │ └── core.prompt.summarize_guidance.md # 上下文压缩时的摘要生成指令
28
+ └── src/ # 纯净的 TS 源码 (绝无硬编码提示词)
29
+ └── agent/
30
+ └── agent-executor.ts
31
+ ```
32
+
33
+ ### 1. 严格的代码契约
34
+ 在核心源码中,获取提示词的唯一方式是通过 `promptRegistry.get(key)` 方法。
35
+ * **铁律**:如果注册表因为某种异常返回了空(`undefined`),代码中**绝对不能**在后面加 `|| "你是一个助理..."` 这样的硬编码兜底字符串。代码必须保持空字符串 `''` 或使用纯占位符,以此强迫开发者修复提示词配置,绝不妥协。
36
+
37
+ ---
38
+
39
+ ## 二、 提示词在内存中的热更新(Hot Update)
40
+
41
+ 在开发调试智能体或者在生产环境进行实时 Prompt A/B 测试时,如果我们每修改一次提示词文件,就需要重启整个 Node.js 后端服务,开发效率将大打折扣。
42
+
43
+ 因此,内核需要实现**热更新机制**:
44
+ * 内核启动时,通过 `promptRegistry` 扫描 `prompts/` 目录下的所有文件并缓存在内存中。
45
+ * 内核提供了在线配置管理接口(API)。一旦管理员通过 API 覆写或局部更新了某个提示词文件,内核在执行磁盘写入的同时,会自动触发内存中的缓存更新。
46
+ * **秒级更新内存,且无需重启 Node.js 进程**,这让提示词的在线微调变得极其轻量与弹性。
47
+
48
+ ```
49
+ [API 收到更新请求] ───> [物理覆写 Markdown 文件]
50
+
51
+ └────────────> [原子更新内存缓存] ───> 下一次自循环 Loop 立即生效
52
+ ```
53
+
54
+ ---
55
+
56
+ ## 三、 【架构避坑】热重载下的高并发“脏读”与“空读”事故
57
+
58
+ 许多开发者在利用外部文件监听器(如 `fs.watch`)或编写重载方法时,会写出类似下面的“直觉代码”:
59
+
60
+ ```typescript
61
+ // ❌ 存在致命并发漏洞的热加载代码
62
+ fs.watch(promptDirectory, async (event, filename) => {
63
+ if (filename.endsWith('.md')) {
64
+ const key = filename.replace('.md', '');
65
+ // 1. 物理清空当前内存中的 Key
66
+ this.promptMap.delete(key);
67
+ // 2. 发起异步磁盘读取
68
+ const content = await fs.promises.readFile(path.join(promptDirectory, filename), 'utf8');
69
+ // 3. 重新写入 Map
70
+ this.promptMap.set(key, content.trim());
71
+ }
72
+ });
73
+ ```
74
+
75
+ ### 1. 并发竞态的物理成因
76
+ 在高并发的生产环境下,上述“先 delete 后 set”的异步代码存在严重的**竞态条件(Race Condition)**:
77
+ 1. 管理员修改了提示词文件并触发重载。
78
+ 2. 代码异步清空了内存 Map 中的该 Key(步骤 1)。
79
+ 3. 就在此时,磁盘读取 `readFile` 尚未完成(可能需要几十毫秒的 I/O 时间)。
80
+ 4. 恰巧,有并发用户的会话刚好执行到 ReAct 自循环内部,开始调用 `promptRegistry` 获取提示词。
81
+ 5. 由于 Map 里的模板已经被 delete 删掉了,系统读到了空字符串。
82
+ 6. **灾难发生**:大模型收到的 System Prompt 失去了世界观和工具约束,开始胡言乱语,导致系统发生严重的安全外泄。
83
+
84
+ ---
85
+
86
+ ## 四、 防御实战:API 同步与内存原子覆写(Atomic Update)
87
+
88
+ 为了彻底杜绝热更新过程中的竞态条件,Freya 内核在物理设计上放弃了不稳定的外部 `fs.watch` 机制,转而采用 **“API 同步与内存原子覆写”** 的防御架构模式。
89
+
90
+ ### 1. 原子热更新代码展示
91
+ Freya 并不在内存 Map 上执行破坏性的 `delete`。当 API 触发提示词写入时,内核会物理落盘,然后通过**同步单线程修改引用对象属性**的方式,对提示词缓存进行瞬间覆写:
92
+
93
+ ```typescript
94
+ // 💡 真实代码:packages/core/src/prompt/prompt-registry.ts 里的原子内容覆写
95
+ export class FreyaPromptRegistry {
96
+ private prompts = new Map<string, FreyaPrompt>();
97
+
98
+ updateContent(key: string, content: string): void {
99
+ const existing = this.prompts.get(key);
100
+ if (existing) {
101
+ // 💡 原子级修改:直接对现有对象的属性进行赋值覆盖,
102
+ // 在 JS 单线程事件循环中此同步操作瞬时完成,没有任何异步 I/O 间隙
103
+ existing.content = content.trim();
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ 在文件管理服务层,我们配合进行同步的物理写入:
110
+
111
+ ```typescript
112
+ // 💡 真实代码:packages/core/src/prompt/prompt-manager.ts 里的物理落盘与内存热更
113
+ export class FreyaPromptManager {
114
+ constructor(private promptRegistry: FreyaPromptRegistry) {}
115
+
116
+ async writePrompt(name: string, content: string): Promise<string> {
117
+ const runFilePath = path.join(PROJECT_ROOT, 'config', `${name.toUpperCase()}.md`);
118
+
119
+ // 1. 异步物理落盘保存
120
+ await fs.mkdir(path.dirname(runFilePath), { recursive: true });
121
+ await fs.writeFile(runFilePath, content, 'utf-8');
122
+
123
+ // 2. 物理写入成功后,调用注册表同步原子更新内存
124
+ const registryKey = `core.prompt.${name.toLowerCase()}`;
125
+ this.promptRegistry.updateContent(registryKey, content);
126
+
127
+ return `提示词文档 "${name}" 物理覆写完成,内存热更新已就绪。`;
128
+ }
129
+ }
130
+ ```
131
+
132
+ ### 2. 物理防脏效果
133
+ 由于 `existing.content = content.trim()` 是一行极其简单的同步属性赋值。
134
+ * 由于 JavaScript 的单线程事件循环(Event Loop)机制,这一覆写操作是一个原子操作(不会被中途抢占)。
135
+ * 对于并发用户的 ReAct 自循环请求,在这一行代码执行前到达的会话读到的是**旧的完整提示词**;在此之后到达的读到的是**新的完整提示词**。
136
+ * **内核中绝对不会出现因为“先清空再重新异步读取磁盘”导致读到 `undefined` 的中间间隙。**
137
+
138
+ 通过这一优雅的 API 同步与内存原子覆写设计,我们既降低了文件监听器的资源消耗,又在物理上为智能体内核的热更新拉起了一条并发安全防线。在下一小节中,我们将进入双通道动态合并的源码深处,看看 Freya 到底是如何在启动时动态加载并合并这些物理提示词的。