问题所在
LLM 的上下文窗口是有限的。一个 128K 令牌的模型听起来很慷慨,直到你减去输出预算、系统提示、工具描述和多轮对话的累积历史。长对话、大型工具结果和多步骤智能体循环都会对这个限制造成压力——通常在单个会话内就会发生。 天真的解决方案是截断:当窗口填满时丢弃旧消息。这很快且可预测,但它会无差别地破坏上下文。用户的原始意图、早期轮次的关键决策和关键数据点在生硬的字符截断时都会消失。相反的极端——在每个轮次上进行 LLM 驱动的摘要——保留了语义内容,但成本高昂、速度慢,并引入了自己的故障模式(幻觉摘要、数值精度丧失)。 真正的挑战不是”适应窗口”。而是:优雅地降级而不丧失关键信息,不在不必要的压缩上浪费令牌,也不增加用户能感受到的延迟。 FIM One 通过五层纵深防御架构解决了这个问题。每一层解决问题的不同规模,它们组合得很干净——没有单一层需要完美,因为下一层会捕捉它遗漏的内容。五层防御
上下文管理不是单一机制。它是一个堆栈,其中每一层在特定的粒度处理特定的问题:| 层级 | 组件 | 功能 | 触发时机 |
|---|---|---|---|
| 5 | 预算配置 | 从模型规格计算可用的输入令牌预算 | 启动时 / 每个请求 |
| 4 | DbMemory | 加载持久化历史记录,加载时压缩 | 每个请求一次 |
| 3 | ContextGuard | 每次迭代的预算强制执行 | 每个 ReAct 迭代 |
| 2 | CompactUtils | 令牌估计、智能截断、LLM 压缩 | 由第 3 和 4 层调用 |
| 1 | 内存实现 | 抽象接口 + 具体策略 | 框架级别 |
第 5 层 — 预算配置
预算由三个值计算得出:128,000 - 64,000 - 4,000 = 60,000 tokens。
4,000 token 的系统提示保留额涵盖智能体的系统提示、工具描述和格式化开销。这是一个固定常数 — 足够大以避免在实践中裁剪系统提示,足够小以不浪费预算。
预算值可以来自三个来源,按优先级顺序解决:
- 数据库 ModelConfig — 由管理员设置的每个模型的
context_size和max_output_tokens。 - 环境变量 —
LLM_CONTEXT_SIZE和LLM_MAX_OUTPUT_TOKENS。 - 硬编码默认值 — 128K 上下文,64K 输出。
Layer 4 — DbMemory
DbMemory 是生产环境中的内存实现。它从数据库加载持久化的对话历史,并将其压缩以适应令牌预算,然后才会被智能体看到。
该设计有意设为只读。持久化由 chat.py 处理——拥有完整消息生命周期的 API 层(包括元数据、使用情况跟踪和图像附件)。DbMemory 仅读取。其 add_message() 和 clear() 方法是空操作。这种分离防止了双重写入,并将持久化逻辑保持在一个地方。
加载时,DbMemory:
- 查询对话的所有
user和assistant消息,按创建时间排序。 - 删除尾部用户消息(当前查询,智能体将重新附加)。
- 重建图像附件——包含图像的用户消息在数据库中存储元数据(
file_id、mime_type),DbMemory从磁盘重建 base64 数据 URL,以便 LLM 可以”看到”之前回合的图像。 - 压缩:如果提供了
compact_llm,使用CompactUtils.llm_compact()。否则,回退到CompactUtils.smart_truncate()。
DbMemory 设置跟踪标志(was_compacted、_original_count、_compacted_count),SSE 层使用这些标志向前端发出 compact 事件。
第 3 层 — ContextGuard
ContextGuard 是每次迭代的预算执行器。它在每个 ReAct 迭代的顶部被调用——既在独立 ReAct 模式中,也在每个 DAG 步骤的子智能体内部。这是消息到达 LLM API 之前的最后一道防线。
执行遵循三步流程:
-
截断超大单条消息。 任何超过 50K 字符的单条消息都会被硬截断,并添加
[Truncated]后缀。这可以捕获失控的工具输出——网页抓取返回整个网页、文件读取转储大型数据集。 - 估计总令牌数。 如果总数在预算范围内,立即返回。大多数迭代在这里通过——压缩是例外,而非常态。
-
如果超出预算则压缩。 如果有可用的
compact_llm,使用带有提示特定提示的 LLM 驱动压缩。否则,回退到smart_truncate。
| 提示 | 使用者 | 保留 | 丢弃 |
|---|---|---|---|
react_iteration | ReAct 智能体循环 | 最近的推理链、当前目标、关键数据 | 旧的冗余步骤、失败的重试、冗长的工具输出 |
planner_input | DAG 丰富查询 | 用户意图演变、关键决策、约束条件 | 对话细节、问候语、工具调用机制 |
step_dependency | DAG 步骤上下文 | 关键数据、数字、结论 | 推理过程、失败的尝试、冗长的格式 |
general | 默认回退 | 关键事实、决策、工具结果 | 问候语、填充词、冗余的往返 |
smart_truncate。智能体永远不会看到失败。这是一个刻意的可靠性选择:通过启发式截断丢失一些上下文比让迭代崩溃要好。
Layer 2 — CompactUtils
CompactUtils 是一个无状态的实用工具类——没有实例,没有状态,只有纯函数。它提供了第 3 层和第 4 层构建的三种能力。
Token 估算将文本转换为近似的 token 计数,无需导入 tokenizer 库。启发式算法:
- ASCII 字符:~4 个字符每 token
- CJK / 非 ASCII 字符:~1.5 个字符每 token
- 图像:765 tokens 每张图像(固定成本)
- 每条消息开销:4 tokens(角色标记、分隔符)
smart_truncate 是启发式回退方案。它无条件地保留固定消息,然后向后遍历非固定消息,累积直到预算耗尽。结果是适合的对话后缀。它还确保结果永远不会以助手消息开头——孤立的助手轮次(前面没有用户消息)会让 LLM 感到困惑。
llm_compact 是 LLM 驱动的路径。它将消息分为三组——系统消息(始终保留)、固定消息(始终保留)和可压缩消息。最旧的可压缩消息被总结为单个 [Conversation summary] 系统消息;最近的 4 条消息保持原样。如果压缩后的结果仍然过长,它会在压缩输出上回退到 smart_truncate——双重保险。
第 1 层 — 内存实现
内存层定义了BaseMemory 接口:add_message()、get_messages()、clear()。存在三种实现:
- WindowMemory — 基于计数的滑动窗口。保留最后 N 条非系统消息。简单、可预测,无 LLM 调用。不用于生产环境;适用于测试和无状态场景。
-
SummaryMemory — 当消息计数超过阈值时触发 LLM 摘要化。将旧消息压缩为
[Conversation summary]系统消息。不用于生产环境;早于更复杂的 ContextGuard 方法。 - DbMemory — 生产实现(在第 4 层中描述)。数据库支持,只读,在加载时使用 LLM 或启发式压缩。
ReAct 中的上下文流动
ReAct 智能体在两个不同的阶段使用上下文管理:加载时和迭代时。 工具迭代使用非流式chat() 以提高速度;答案合成使用通过 stream_answer() 的流式 stream_chat()。这种两阶段分割——快速工具循环后跟流式合成——优化了延迟和用户体验。有关包括双模式执行和工具选择的完整 ReAct 引擎架构,请参阅 ReAct 引擎。
关键洞察:DbMemory 处理历史上下文问题(来自先前请求的轮次),而 ContextGuard 处理请求内增长问题(工具结果在智能体循环期间累积)。 它们在不同的时间尺度上运行并捕获不同的故障模式。
用户的当前查询始终标记为 pinned=True。这确保它在所有压缩中都能保留——smart_truncate 和 llm_compact 都无条件地保留固定消息。无论历史记录压缩得多么激进,用户的实际问题永远不会丢失。
上下文如何在 DAG 中流动
DAG 模式的上下文形状与 ReAct 根本不同。它不是一个长对话线程,而是一棵树:一个规划阶段、多个并行执行步骤和一个分析阶段。每个阶段都有自己的上下文管理策略。 阶段 1 — 历史加载。 DbMemory 加载并压缩对话历史,与 ReAct 相同。压缩后的历史被格式化为以"Previous conversation:" 为前缀的文本块。
阶段 2 — 富化查询构建。 历史文本和当前查询被组合成 enriched_query。如果超过 16K 个令牌,将使用 planner_input 提示词通过 LLM 进行总结。选择 16K 阈值是因为规划器需要在一次传递中读取整个查询 — 与 ReAct 不同,规划期间没有迭代压缩。
阶段 3 — 规划。 规划器接收一个 2 消息提示:系统提示加富化查询。这里没有 ContextGuard — 富化查询已经通过 16K 检查进行了大小控制。
阶段 4 — 步骤执行。 每个 DAG 步骤作为独立的 ReAct 智能体运行,具有自己的 ContextGuard。关键是,这些子智能体没有记忆 — 它们从仅有的任务描述和依赖上下文开始。这是设计上的考虑:DAG 步骤应该是自包含的工作单元。依赖结果通过 _build_step_context 注入,该函数在 50K 处进行字符截断(ContextGuard 的 max_message_chars 限制)。
阶段 5 — 分析。 步骤结果被格式化供分析器 LLM 使用,每个步骤在 10K 字符处进行截断。这可以防止单个步骤的冗长输出主导分析上下文。
阶段 6 — 重新规划。 当分析器确定目标未实现且置信度低于阈值时,步骤结果被截断为仅 500 字符用于重新规划上下文。重新规划需要知道发生了什么和哪里出错了,而不是每个步骤输出的完整细节。这种激进的截断使重新规划提示足够紧凑,以便规划器能够高效处理。
有关完整的 DAG 管道架构(包括 LLM 调用映射和重新规划逻辑),请参阅 DAG 引擎。
固定消息
固定机制防止压缩破坏必须保留的消息。有两类消息被固定:- 当前用户查询 — 始终固定。如果用户提出问题且历史记录过长,系统会压缩历史记录,而不是问题。
- 中途注入的消息 — 当用户在智能体仍在运行时发送后续消息时,注入的消息被标记为固定,以便智能体在下一次迭代中看到它。
令牌估计
FIM One 使用启发式令牌估计而不是真实的分词器。这是一个经过深思熟虑的选择,具有明确的权衡。 为什么不使用真实的分词器? 三个原因:-
依赖成本。
tiktoken(OpenAI 的分词器)是 15MB 的编译 Rust 绑定。sentencepiece(由某些开源模型使用)有自己的构建要求。对于针对多个 LLM 提供商的框架,没有单一的正确分词器——每个模型系列使用不同的分词器。 - 速度。 启发式估计是对字符串的单次遍历。真实分词涉及词汇表查找、BPE 合并操作和特殊令牌处理。ContextGuard 在每次迭代时调用估计,有时多次——速度差异很重要。
- 足够好。 启发式方法针对混合语言文本进行了调整(ASCII/CJK 分割涵盖了两个主要情况)。对于边界情况(大量标点符号的代码、不寻常的 Unicode),可能偏差 1.5-2 倍,但上下文管理本质上是近似的。在 60K 预算上偏差 30% 仍然留下舒适的余量。
| 内容类型 | 比率 | 原理 |
|---|---|---|
| ASCII 文本 | ~4 字符/令牌 | 英文散文和代码在 GPT/Claude 分词器中平均为 3.5-4.5 字符/令牌 |
| CJK / 非 ASCII | ~1.5 字符/令牌 | 每个 CJK 字符通常为 1-2 个令牌;1.5 是几何平均值 |
| 图像 | 765 令牌/图像 | 视觉 API 中 base64 编码图像的近似成本 |
| 每条消息开销 | 4 令牌 | 角色标记、分隔符、格式化 |
用户看到的内容
上下文管理的设计目标是在常见情况下保持隐形,仅在激活时产生最小干扰。用户可见的信号包括: CompactDivider。 当DbMemory 在加载时压缩历史记录时,前端会渲染一条虚线分隔符,显示文本”较早的上下文(N 条消息)已由 AI 总结。“这会出现在摘要和保留的最近消息之间,为用户提供视觉提示,表明较旧的上下文已被压缩,而不会中断对话流。
令牌使用显示。 每个响应末尾的 done 卡片显示”X.Xk in / X.Xk out”——消耗的总输入和输出令牌。这包括在压缩上花费的令牌(用于总结的快速 LLM 调用)。监控令牌消耗的用户可以看到压缩何时增加了开销。
优雅的错误处理。 如果上下文管理完全失败——考虑到回退链,这种情况不应该发生,但理论上可能发生——错误会作为响应中的智能体错误文本出现,而不是系统崩溃。对话继续进行;用户可以重试或重新表述。
目标是大多数用户永远不需要考虑上下文管理。他们进行长对话,系统透明地处理预算,唯一可见的工件是偶尔出现的压缩分隔符。对于关心令牌效率的高级用户和运营人员,使用显示和可配置的预算参数提供了他们需要的控制。