跳到主要内容

数据截至 (上游 commit dad6f5196773)

上下文工程:每次调模型前,消息和工具是怎么被拼出来的

30 秒导读: LobeHub 里,数据库存的消息是给人看的一棵树(assistantGroup、agentCouncil、tasks、verify 卡片……),而模型只认一条扁平的 {role, content} 数组packages/context-engine 就是这两者之间那台机器:一条顺序执行的处理器流水线,把树压平、把该注入的知识/记忆/技能/日期塞进正确的插槽、把工具清单编码成合法函数名,最后吐出一份可以直接交给 call_llm 的载荷。

本章只讲 call_llm 之前那一段。指令是谁发出来的、引擎怎么跑,见 运行时内核;拼好的载荷怎么送进几十家供应商,见 模型运行时


1. 这是什么(零基础也能懂)

一句话定义: context-engine 是 LobeHub 的提示词装配线——输入是一堆 UI 用的消息对象和一堆配置,输出是模型 API 能直接消费的 messages 数组和 tools 数组。

它要解决的问题

想象你在 LobeHub 里发了一句"帮我看看这个 PDF"。等模型真正收到时,这句话周围其实还挂着一大堆东西:

  • 这个 agent 的人设(system role);
  • 今天的日期、模型的知识截止日期;
  • 你的长期记忆、这个 agent 的知识库文件;
  • 当前可用的技能清单、工具清单;
  • 上一轮对话的压缩摘要;
  • 你刚才勾选的那几个工具。

这些东西各有各的正确位置:人设必须在 system 消息里,知识库放在第一条 user 消息之前(前缀稳定 → 命中缓存),todo 列表得贴在最后一条 user 消息末尾(要反映最新状态)。同时,数据库里那些"一个 assistant 带三个工具结果"的合并卡片,必须先拆回 assistant → tool → tool → tool 的扁平序列,否则模型直接报 400。

把这套规则写成一坨 if-else,就是所有聊天产品最后都会烂掉的那个文件。 context-engine 的答案是:拆成几十个独立的小处理器,排成一条流水线。

用起来什么样

最小调用长这样(真实签名见 packages/context-engine/src/engine/messages/MessagesEngine.ts:101MessagesEngine):

// 示意,非源码
const engine = new MessagesEngine({
messages, // 数据库里的 UIChatMessage[]
model: 'gpt-4o',
provider: 'openai',
systemRole: 'You are a helpful assistant',
capabilities: { isCanUseFC: () => true, isCanUseVision: () => true },
});

const { messages: payload, metadata, stats } = await engine.process();
// payload 就是 OpenAI 格式的 messages 数组

一句话直觉

把它当成印刷厂的装版流程。 稿件(消息)先裁掉多余的页(截断),再依次盖上页眉(system prompt)、插页(知识/记忆)、页脚(todo/工具选择),最后统一校对字号(格式清洗)才上机。每道工序都不知道别的工序存在,只认自己那一步。


2. 顶层全景(它大概怎么转)

一张图

怎么读:从上往下就是执行顺序;左侧是"数据长什么样",右侧是"这一层由谁负责"。

输入: UIChatMessage[] (数据库/前端 store 里那棵给人看的对话树)


┌──────────────────────────────────────────────┐
│ ContextEngine.process() │ pipeline.ts
│ ── 顺序跑 processors,带耗时统计与提前终止 ── │
└──────────────────────────────────────────────┘
│ 流经的是同一个 PipelineContext 对象

├─ ① 裁剪 砍掉过老的历史(按"组"砍,不腰斩)
├─ ② 装 system 人设 / 日期 / 技能清单 / 工具说明 / 历史摘要
├─ ③ 首 user 前 知识库 / 用户记忆 / 计划 / 群组身份
├─ ④ 末 user 后 todo / 选中工具 / 页面内容 / 话题引用
├─ ⑤ 形变 把树压平:群聊卡片 / 任务卡片 / 角色改写
├─ ⑥ 内容 图片转 base64 / tool_calls 转 OpenAI 格式
└─ ⑦ 收尾 tool 消息配对重排 → 字段清洗


输出: OpenAIChatMessage[] + metadata(每步做了什么) + stats(每步耗时)

旁路(不在 pipeline 里,但同样喂给 call_llm):
engine/tools/ → tools[] 工具清单与合法函数名
engine/skills/ → skills 技能激活状态
tokenAccounting/ → 该不该压缩了

部件一句话职责

部件干什么在哪个文件
ContextEngine顺序跑处理器,统计耗时,包错误packages/context-engine/src/pipeline.ts:19
ContextProcessor处理器唯一契约:name + process()packages/context-engine/src/types.ts:89
PipelineContext流经全程的可变状态包packages/context-engine/src/types.ts:70
base/*四种"往哪儿注入"的插槽基类packages/context-engine/src/base/
processors/*清洗、裁剪、模板、形变、任务packages/context-engine/src/processors/
providers/*各类内容注入器(谁塞什么)packages/context-engine/src/providers/
MessagesEngine预置好的 7 阶段默认流水线packages/context-engine/src/engine/messages/MessagesEngine.ts:101
engine/tools/*工具清单装配 + 函数名编解码packages/context-engine/src/engine/tools/
engine/skills/*技能集装配与激活合并packages/context-engine/src/engine/skills/
tokenAccounting/*分类估算 token,判定压缩阈值packages/context-engine/src/tokenAccounting/

主线走一遍(不进代码)

  1. 调用方(浏览器或服务端)收集好一切上下文,new MessagesEngine({...})
  2. MessagesEngine.buildProcessors() 按 7 个 Phase 把几十个处理器排成一个数组(MessagesEngine.ts:145)。
  3. ContextEngine.process() 从头到尾跑一遍,每步都可能改写 context.messages
  4. 拿到扁平消息数组;工具清单由 ToolsEngine 另外算好;两者一起送去调模型。

3. 管道骨架:ContextEngine.process()

这节讲什么: 流水线本身有多简单——简单到值得直接读完。

3.1 处理器契约只有两个字段

// packages/context-engine/src/types.ts:89
export interface ContextProcessor {
name: string;
process: (context: PipelineContext) => Promise<PipelineContext>;
}

没有生命周期钩子、没有依赖声明、没有优先级数字。 顺序 = 数组顺序。这是整个包最重要的设计决定:所有"谁先谁后"的知识,集中写在 MessagesEngine.buildProcessors() 一个地方,而不是散落在几十个类的元数据里。

3.2 流经全程的那个状态包

PipelineContext(types.ts:70)只有四个字段:

字段可变吗用来干嘛
initialStatereadonly原始输入快照,处理器想回看"用户本来说了什么"时用
messages可变正在被搭建的消息数组,每步都可能改
metadata可变处理器之间通信 + 事后诊断("我注入了几条")
isAborted / abortReason可变提前终止开关

metadata 的类型用了声明合并扩展:每个处理器在自己文件顶部 declare module '../types'PipelineContextMetadataOverrides(types.ts:13)上加自己的字段。例如 MessageCleanup.ts:6-13 声明了 messageCleanup: { cleanedCount, totalMessages }。好处是类型安全但零中心注册表——加一个处理器不用改公共类型文件。

3.3 主循环三件事

process()(pipeline.ts:66)本体就三件事:

// packages/context-engine/src/pipeline.ts:87-109(节选)
for (const processor of this.processors) {
if (context.isAborted) break; // ① 提前终止
const processorStartTime = Date.now();
context = await processor.process(context);
processorDurations[processor.name] = Date.now() - processorStartTime; // ② 逐步计时
if (context.isAborted) break;
}

注意 isAborted 检查了两次:进循环前查一次(上一步设的),跑完再查一次(这一步设的)。终止不靠抛异常,靠 BaseProcessor.abort()(base/BaseProcessor.ts:67)返回一个带 isAborted: true 的新 context——正常返回,不是异常路径,所以 stats 依然完整。

计时结果最终落在 PipelineResult.stats.processorDurations(types.ts:119-126),是一张 处理器名 → 毫秒 的表。谁拖慢了发消息,一眼可见。

3.4 两层错误:ProcessorError 包在 PipelineError

出错时的包装是双层的,两层都实现了 toJSON() 以便日志聚合器吞下整条因果链:

processor 内部抛 Error
│ BaseProcessor.process 捕获(base/BaseProcessor.ts:29-32)

ProcessorError("[名字] Processing failed: ...", cause) types.ts:198
│ ContextEngine 捕获(pipeline.ts:110-129)

PipelineError("Processor [名字] execution failed: 摘要", 名字, cause) types.ts:234

有个小细节值得学:pipeline.ts:117-121 把 cause 的 message 截到 300 字符再拼进 PipelineError 的 message 里。目的是让只能看到顶层报错文案的看板使用者也能直接分诊,不必翻堆栈;同时避免一条超长的模型返回把日志撑爆。

3.5 还有个 validate()

pipeline.ts:180validate() 检查三件事:处理器重名、流水线为空、缺 name/process重名检查不是洁癖——processorDurations 是以 name 为 key 的 map,重名会互相覆盖统计。


4. 四种插槽:base/ 基类家族

这节讲什么: 几十个注入器之所以不会打架,是因为"往哪儿塞"这件事只有四种答案,每种由一个基类实现。

4.1 为什么必须区分插槽

同样是"给模型多点信息",位置不同,后果完全不同:

  • system:全局约束,但改一个字就让整段 prompt 前缀失效(缓存全丢)。
  • 第一条 user 之前:内容稳定(知识库、记忆),前缀可复用,缓存友好。
  • 最后一条 user 之后:反映当前状态(todo 进度、页面内容),必须最新。
  • 每一条 user 上:逐条绑定的上下文(每条消息各自的划选文本)。

4.2 插槽全图

怎么读:方框是消息数组里的位置,箭头标注哪个基类往这里写。

[ system ] ←── BaseSystemRoleProvider 追加,用 \n\n 拼接;没有就新建并 unshift

[ user#1 前的"注入消息" ] ←── BaseFirstUserContentProvider
│ (meta.systemInjection = true 标记,后来者追加进同一条)
[ user#1 ] ←┐
[ assistant ] │
[ user#2 ] ←┼── BaseEveryUserContentProvider 逐条 user 都写一份
[ assistant ] │
[ user#N ] ←┘ ←── BaseLastUserContentProvider 追加到最后一条 user 的尾巴

[ (若尾部不是 user 则新建一条合成 user) ] ←── BaseVirtualLastUserContentProvider

4.3 五个基类对照表

基类注入位置多个注入器同时用时怎么合并典型子类
BaseSystemRoleProvidersystem 消息找到就 \n\n 追加,没有就新建插到最前SystemRoleInjectorSystemDateProvider
BaseFirstUserContentProvider第一条 user 之前首个创建带 meta.systemInjection 的 user 消息,后续全部追加进这一条KnowledgeInjectorUserMemoryInjector
BaseLastUserContentProvider最后一条 user 末尾复用已有的 SYSTEM CONTEXT 包裹,插在结束标记之前TodoInjectorSelectedToolInjector
BaseEveryUserContentProvider每一条 user 末尾同上,逐条独立;注入条数记进 metadataPageSelectionsInjector
BaseVirtualLastUserContentProvider消息数组的尾部尾部是 user 就追加,不是就新建一条合成 userOnboardingActionHintInjector

另有一个退化基类 BaseProvider(base/BaseProvider.ts:7),职责被注释明确限定为"在开头注入 system 消息",实际只有 ForceFinishSummaryInjector 用它(而且是 push 到末尾)。

4.4 合并的实现细节

system 侧只有 12 行(base/BaseSystemRoleProvider.ts:44-62):找 role === 'system' 的下标,有就 [existing.content, content].filter(Boolean).join('\n\n'),没有就造一条 unshift 进去。子类只需实现 buildSystemRoleContent() 返回字符串或 null,返回空就自动跳过。

首 user 侧靠一个字符串标记做汇聚:SYSTEM_INJECTION_MARKER = 'systemInjection'(base/BaseFirstUserContentProvider.ts:7),写在消息的 meta 上。第二个注入器进来时先 findSystemInjectionMessageIndex(),命中就追加到同一条(:119-126),没命中才在第一条 user 前 splice 一条新的(:137)。结果是所有静态上下文合并成一条 user 消息,而不是十条。

末 user 侧多一层包裹协议。注入内容会被套上一组注释标记(base/constants.ts:9-10):

<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
<context.instruction> …告诉模型:优先处理用户可见内容,上下文仅在需要时使用… </context.instruction>
<todo_context> …实际内容… </todo_context>
<!-- END SYSTEM CONTEXT -->

第二个注入器发现已有包裹时,不再套一层,而是用 insertIntoExistingWrapper()(base/BaseLastUserContentProvider.ts:51)把新块插到 END 标记之前。所以一条 user 消息尾部永远只有一个 SYSTEM CONTEXT 区块,里面可以有 N 个子标签。 顺带说明了为什么 BaseEveryUserContentProvider 的类注释(:14-15)特意写明"必须跑在 BaseLastUserContentProvider 之前"——它负责创建包裹,后者负责往里塞。

4.5 虚拟末尾 user:为缓存让路

BaseVirtualLastUserContentProvider 的类注释(base/BaseVirtualLastUserContentProvider.ts:9-19)讲清了动机:高频变动的运行时指引不能放 system,否则每轮都把稳定前缀打脏、缓存全失效。 所以它一律往尾巴上挂;若尾部不是 user 消息(比如刚跑完一个 tool),就造一条带 meta.virtualLastUser = true 的合成 user 消息(:50-59)。多个虚拟注入器会复用同一条合成消息。

4.6 所有基类共享的模板方法

BaseProcessor(base/BaseProcessor.ts:8)用模板方法把公共动作固定下来:

// packages/context-engine/src/base/BaseProcessor.ts:23-33(节选)
async process(context) {
try {
this.validateInput(context); // messages 必须是数组
const result = await this.doProcess(context);
this.validateOutput(result);
return result;
} catch (error) { throw new ProcessorError(this.name, `Processing failed: ...`, cause); }
}

子类只实现 doProcess(),并且约定俗成先 cloneContext()(:56,浅拷贝 messages 数组和 metadata 对象)再改。这是"写时复制"的弱版本:数组本身是新的,元素仍是共享引用,所以处理器改元素时都用 {...msg, xxx} 而不是原地赋值。


5. processors/:把给人看的树压成给模型看的数组

这节讲什么: 20 个处理器按职责分五类。先看分类,再挑代表深挖。

类别成员共同目的
清洗MessageCleanupMessageContentToolMessageReorderDisabledToolCallFilter让载荷符合 OpenAI 格式与模型能力
裁剪HistoryTruncate控制历史长度
模板变量InputTemplatePlaceholderVariables{{var}} 渲染成真值
多 agent 形变GroupMessageFlattenGroupRoleTransformCompressedGroupRoleTransformSupervisorRoleRestoreAgentCouncilFlattenGroupOrchestrationFilter把群聊语义翻译成单模型视角
任务TaskMessageTasksFlattenTaskCallbackMessageVerifyMessage把任务卡片变成模型能读的对话轮次

5.1 清洗类

MessageCleanupProcessor(processors/MessageCleanup.ts:21)—— 最后一道工序,做减法。 按 role 白名单挑字段(:57cleanMessage):system/user 只留 content + role;assistant 额外留 tool_callsreasoning;tool 留 tool_call_id 和可选 name。数据库里那些 pluginStateeditorDatachunksList 全部在这里蒸发。

ToolMessageReorder(processors/ToolMessageReorder.ts:28)—— 全包最"救命"的一个。 模型 API 有个硬不变式:每个 tool_calls[i].id 后面必须紧跟一条 tool_call_id 相同的 tool 消息;多一条、少一条、顺序错了,直接 400。而 UI 里用户可以删消息、重发、并发触发,这个不变式随时会破。

它的修复分三步(:71reorderToolMessages):

① 扫一遍 assistant,收集所有合法 tool_call_id → validToolCallIds
② 扫一遍 tool 消息,按 id 建索引;孤儿 / 重复者丢弃并计数
③ 重新拼装:遇到 assistant(带 tool_calls)就把它的 tool 结果按 tool_calls 顺序紧跟其后
找不到结果的 → 合成一条 {"error":"Tool call failed","success":false,"synthetic":true}

第 ③ 步那条合成的失败结果(常量在 :18)是关键:宁可编一个明确的失败,也不能让配对断掉。 另外 :147-158 还有一个补位:tool 消息 content 为空但带 pluginError.message 时,用错误文案顶上,而不是发一条空字符串给模型。

DisabledToolCallFilter(processors/DisabledToolCallFilter.ts:56)—— 防"模仿"。 类注释(:50-54)点破了问题:即使本轮 tools 里已经没有某工具,模型也会从历史消息里照抄那个工具名再调一次。解法是把历史里该工具的 tool_calls 直接删掉,断掉模仿路径;留下的孤儿 tool 结果交给后面的 ToolMessageReorder 收尾。匹配同时支持精确名和 identifier____ 前缀(:23-33)。

MessageContentProcessor(processors/MessageContent.ts:97)—— 多模态降级。 模型没有视觉能力时,图片不能直接丢(丢了模型就不知道"用户发过图"),也不能原样发(DeepSeek 这类会直接 400)。折中是替换成占位文本 [image omitted: not supported by this model](:28),数量与原图数量一致(:210),并且插在用户自己的文本之后、SYSTEM CONTEXT 区块之前——注释(:203-207)解释了原因:它代表用户真发过的图,贴着用户文本才符合对话流。

5.2 裁剪类:HistoryTruncate 按"组"砍而不是按条砍

要解决的小问题: 用户设置"只保留最近 10 条",但 UI 上的"一条"其实往往是好几条数据库消息(一个 assistant + 它的三个工具调用 + 三个结果)。按条砍会把一个组腰斩,留下没有 assistant 的孤儿 tool 消息 —— 模型直接报错。

思路: 先把消息重新分组(还原 UI 的分组逻辑),再按数往回数。

核心函数 getSlicedMessages()(processors/HistoryTruncate.ts:222)分四步:

Step 1 正向扫描,识别组起点 isGroupStart() :117
四种起点:assistant 带 tools / tool 带 agentCouncil 标记 / compare 标记 / 多兄弟 task 的头一个
Step 2 从尾往前数,选够 historyCount 个组
Step 3 展开被选中组的全部消息 id
Step 4 按原顺序过滤原数组(保持顺序,不重排)

组的收集靠 collectGroupIds()(:152),其中 assistant 链是递归的(collectAssistantGroupIds,:67):assistant → 它的 tool → tool 的第一个子消息,如果还是同一个 agentId 的 assistant 就继续往下收。递归有两个刹车(:93-100):tool 带 agentCouncil 标记时停,tool 底下挂了多个 task 子消息时也停——那两种情况是新的组,不该被并进来。

关键位置:这是 MessagesEnginePhase 1,注释写明"MUST run first"(MessagesEngine.ts:264-268)。所有后续注入都只作用在裁剪后的消息上,否则会白白给砍掉的消息注入内容。

5.3 模板变量类

InputTemplateProcessor(processors/InputTemplate.ts:24) 处理"用户输入模板"这个 agent 配置项。它只认一个变量:

// packages/context-engine/src/processors/InputTemplate.ts:47-49
const compiler = template(this.config.inputTemplate, {
interpolate: /\{\{\s*(text)\s*\}\}/g,
});

正则里硬编码了 text——别的 {{xxx}} 在这一步一律不动,留给下一个处理器。编译失败或单条渲染失败都只是打日志继续(:71-80),不炸整条流水线。

PlaceholderVariablesProcessor(processors/PlaceholderVariables.ts:301) 才是通用变量渲染:走一个 变量名 → () => string 的生成器表,支持递归展开(默认深度 2,即变量的值里还能再含变量)。

它的工程质量值得单独一提,三处防御都是真事故留下的疤:

  1. buildContentPreview()(:24)的注释直说:JSON.stringify(undefined) 返回的是 undefined 而非字符串,直接 .slice() 会炸——一个 content 为 undefined 的工具错误结果曾因此拖垮整个处理器。
  2. 单条消息渲染失败只收集进 failures 数组继续跑(:381-390),并把诊断挂到 metadata.placeholderVariablesFailures,而不是抛出。
  3. 找不到生成器的变量名(大概率是用户拼错,如 {{nickName}})会被单独收集成 unresolvedPlaceholders(:266extractUnresolvedPlaceholderNames),即便渲染"成功"也会记录。

顺序上有一条铁律,写在 MessagesEngine.ts:503-524 的长注释里:PlaceholderVariables 必须跑在所有 flatten / 角色形变之后。原因是带 {{}} 的内容常常藏在 children[].tools[].result.content 这种嵌套结构里,而这个处理器只走 message.content;只有等 flatten 把嵌套内容提升成顶层 tool 消息,它才看得见。

5.4 多 agent 形变类

这一族解决同一个问题:数据库里有一堆模型根本不认识的 role。

处理器输入 role输出干了什么
AgentCouncilFlattenProcessoragentCouncilassistant + tool 序列展开并行广播的 members[],成员若是 assistantGroup 还要再拆一层(AgentCouncilFlatten.ts:163)
GroupMessageFlattenProcessorassistantGroup / supervisorassistant + tool 序列展开 children[],每个 child 生成一条 assistant 和它的 tool 结果
SupervisorRoleRestoreProcessorsupervisorassistant纯改名,UI 用的角色还原回模型认识的
CompressedGroupRoleTransformProcessorcompressedGroupuser改名并把摘要裹进 <compressed_history_summary>
GroupRoleTransformProcessor其他 agent 的 assistant/tooluser + 说话人标签改写视角
GroupOrchestrationFilterProcessorsupervisor 的编排消息删除降噪

GroupRoleTransform 的视角改写值得细看。 类注释(processors/GroupRoleTransform.ts:50-53)把规则讲得非常干净:

从模型的视角看,role: assistant = "我说的话",role: user = "外部输入"。

所以在群聊里,别的 agent 说的话对当前 agent 而言就是外部输入,必须转成 user,并加一个 <speaker name="..." /> 标签(:209)标明是谁说的。为什么不干脆保留 assistant 角色加个前缀?注释第 49 行给了理由:那样模型会开始模仿说话人标签,在自己的输出里也带上 <speaker>

GroupOrchestrationFilter(processors/GroupOrchestrationFilter.ts:100) 则是纯降噪:supervisor 调 broadcast / speak / executeTask 这些编排工具的记录(默认清单在 :26),对参与者 agent 毫无价值,只会烧上下文并让模型困惑。过滤规则分得很细(:84-92 注释):supervisor 的无工具发言保留(可能是有价值的总结),supervisor 调非编排工具(比如搜索)也保留,只删编排调用及其结果。而且当前 agent 自己就是 supervisor 时整个处理器跳过(:127-130)——它得看见自己的编排历史。

5.5 任务类

四个处理器,处理"任务系统"产生的四种卡片:

  • TasksFlattenProcessor(processors/TasksFlatten.ts:25):tasks / groupTasks 聚合卡片 → 一条条独立的 role: 'task' 消息,顺便把 parentId/threadId/groupId/topicId 补齐。
  • TaskMessageProcessor(processors/TaskMessage.ts:57):role: 'task'assistant,并用模板把指令和结果拼在一起(默认模板在 :20:[Task Result from X] + 指令 + 结果)。没有指令时就直接改 role。
  • TaskCallbackMessageProcessor(processors/TaskCallbackMessage.ts:27):任务完成回调卡 → user 轮次,裹进 <task_result task="..." status="...">。注释(:20-23)解释了为什么是 user 而不是 assistant:要让创建者 agent 读成"任务在向我汇报",而不是人类新说了一句话。
  • VerifyMessageProcessor(processors/VerifyMessage.ts:28):交付检查卡。空内容的纯 UI 卡直接从模型上下文里删掉;带自动修复反馈的则转成 user 轮次裹进 <delivery_check_feedback>

这两个处理器共享一个漂亮的模式:同一条消息,在 UI 里是卡片,在模型上下文里可能是一条 user 轮次、也可能压根不存在。flatMap 返回 [] 实现删除,返回改写后的单元素数组实现转换。


6. providers/:谁负责往 system prompt 里塞什么

这节讲什么: 30 多个注入器,记住"每个塞什么、塞哪儿"就够了。

6.1 主要注入器速查

注入器插槽塞进去的内容文件
SystemRoleInjectorsystemagent 人设(通常是第一个,后面的都往它后面追加)providers/SystemRoleInjector.ts:27
SystemDateProvidersystemCurrent date: YYYY-MM-DD (TZ)providers/SystemDateProvider.ts:19
ModelInfoProvider(含 knowledge cutoff)systemModel knowledge cutoff: ...providers/ModelInfoProvider.ts:75(原 ModelKnowledgeCutoffProvider 已并入)
SkillContextProvidersystem已激活技能的全文 + 其余技能的 <available_skills> 清单providers/SkillContextProvider.ts:68
ToolSystemRoleProvidersystem各工具 manifest 的 systemRole 与 API 说明providers/ToolSystemRole.ts:53
HistorySummaryProvidersystem<chat_history_summary> 压缩摘要providers/HistorySummary.ts:40
UserMemoryInjector首 user 前用户长期记忆(persona/identities/preferences…)providers/UserMemoryInjector.ts:31
KnowledgeInjector首 user 前agent 挂的文件内容 + 知识库信息providers/KnowledgeInjector.ts:30
PlanInjector首 user 前<plan> 目标与背景(已完成的计划不注入)providers/PlanInjector.ts:68
ToolDiscoveryProvider首 user 前可动态激活的工具目录providers/ToolDiscoveryProvider.ts:29
GroupContextInjector首 user 前当前 agent 在群里的身份与成员表providers/GroupContextInjector.ts:93
TodoInjector末 user 后<todos> + 完成进度统计providers/TodoInjector.ts:78
SelectedToolInjector末 user 后用户本轮 @ 选中的工具providers/SelectedToolInjector.ts:105
SelectedSkillInjector末 user 后用户本轮 / 选中的技能providers/SelectedSkillInjector.ts:107
TopicReferenceContextInjector末 user 后<refer_topic> 引用的其他话题摘要providers/TopicReferenceContextInjector.ts:95
PageSelectionsInjector每条 user该条消息各自的页面划选文本providers/PageSelectionsInjector.ts:25
OnboardingActionHintInjector虚拟末尾引导阶段的动作提示(高频变动)providers/OnboardingActionHintInjector.ts:42
ForceFinishSummaryInjector消息末尾 push"已达步数上限,请总结并给最终答复,不要再用工具"providers/ForceFinishSummaryInjector.ts:27

6.2 ForceFinishSummaryInjector:与运行时内核的接口

第 1 章讲的 forceFinish(agent 跑到最大步数)在上下文层的落点就是这一个注入器。它的实现只有一句话的分量(providers/ForceFinishSummaryInjector.ts:44-50):往消息数组末尾 push 一条 system 消息,内容是要求总结、禁止再调工具。

配套动作在工具侧:buildStepToolDelta 遇到 forceFinish 会设 deactivatedToolIds = ['*'](engine/tools/buildStepToolDelta.ts:65-67),ToolResolver 见到 '*' 直接返回空工具数组(engine/tools/ToolResolver.ts:84-92)。提示词和工具清单两头一起下手,才真的关得住。 位置在 MessagesEngine.ts:563,倒数第二个——只在 MessageCleanup 之前。

6.3 AgentDocumentInjector:同一份文档,五个可选落点

Agent 文档(给 agent 挂的长期文档)不是一个注入器,是一族。因为同一份文档,作者可能希望它落在完全不同的位置,所以位置被做成了配置项(providers/AgentDocumentInjector/shared.ts:12-21,共 8 个位置常量),对应五个实现:

位置语义
AgentDocumentBeforeSystemInjector在 system 消息之前独立插一条
AgentDocumentSystemAppendInjector追加到 system 消息
AgentDocumentSystemReplaceInjector整体替换 system 消息(破坏性,所以排在 Phase 2 最后,MessagesEngine.ts:320)
AgentDocumentContextInjector第一条 user 之前
AgentDocumentMessageInjectorafter-first-user / context-end

文档还带加载规则(filterDocumentsByRules,shared.ts:52):支持 always / 按关键词 / 按正则 / 按时间段——即"这份文档只在用户提到某些词时才注入",复用了 database 层的 matchesLoadRules


7. 工具装配:engine/tools/

这节讲什么: 从"用户装了哪些插件"到"发给模型的那个 tools 数组",中间隔着五道工序。

7.1 ToolsEngine.generateTools() 的四步

engine/tools/ToolsEngine.ts:55:

输入 toolIds(本轮想开的) + defaultToolIds(构造时注入的常驻工具)

① 合并去重 [...new Set([...toolIds, ...defaultToolIds])] :59-61
│ skipDefaultTools=true 时只用 toolIds(广播场景要彻底关工具)

② 模型支不支持 function calling? functionCallChecker :161
│ 不支持 → 直接 return undefined,后面全不用算

③ 逐个过 enableChecker,分成 enabled / filtered(带原因) :176
│ 原因三选一: not_found | disabled | incompatible

④ manifest → UniformTool:每个 API 一个函数定义 :261

还有个 generateToolsDetailed()(:99)返回完整的过滤明细,供 UI 展示"为什么这个工具没生效";它额外支持 excludeDefaultToolIds,用于"手动技能模式下只排除发现类工具"。

7.2 工具名编码:ToolNameResolver

要解决的小问题: 模型只会返回一个扁平的函数名字符串,但 LobeHub 需要从中恢复出三样东西:哪个插件、哪个 API、什么类型。而 OpenAI 规定函数名 ≤ 64 字符,严格的供应商还拒绝非 ASCII、点、斜杠、空格。

思路:____ 拼接三段,超长或非法就把那一段换成 MD5 前缀,靠 manifest 反查还原。

编码流程(engine/tools/ToolNameResolver.ts:92generate):

identifier ____ apiName [____ type]
│ 每段先过 normalizeComponent():不匹配 /^[\w-]+$/ 就换成 MD5HASH_xxx :38

├─ 总长 < 64 ? → 直接用
├─ ≥ 64 → 把 apiName 换成 MD5HASH_(hash 前 12 位) :64-67
└─ 还 ≥ 64 → identifier 也换成 MD5HASH_ :69-72

解码(:89resolve)是反过程,但多了两层防御:

  1. 缺分隔符的兜底。 模型有时会返回裸的 activateTools 而不是 lobe-activator____activateTools。此时在所有 manifest 里找同名 API,只有唯一匹配才恢复,多个匹配就返回 null 丢弃(:110-132)。
  2. offeredToolNames 白名单。 兜底匹配被限制在"本轮真的发给模型的工具名"集合内(:121)。注释(:83-87)讲清了理由:否则模型可以靠一个裸名字触发本轮没启用的工具,而且被禁用的同名工具还会让启用的那个看起来"有歧义"。这是一条实打实的越权防线。

MD5 反解则是逐个算 hash 比对(:135-144 反解 identifier,:156-166 反解 apiName)——因为哈希不可逆,只能在候选集里正向重算。

7.3 三个小而关键的工具函数

normalizeToolParameters(engine/tools/utils.ts:27)——七行代码,注释比代码长:JSON Schema 允许省略 required,但百炼、智谱这些 OpenAI 兼容上游经过中间代理归一化后会收到 required: null 而报错。所以只要是 type: 'object' 且没有 required 数组,就补一个 required: []

createEnableChecker(engine/tools/enableCheckerFactory.ts:35)——把"某个工具该不该开"的判定做成四级瀑布:

① isExplicitActivation 且允许绕过 → 开(activator 显式激活的工具)
② platformFilter 返回 true/false → 用它;返回 undefined → 继续往下
③ rules[pluginId] 存在 → 用它
④ 都没命中 → 关(默认拒绝)

默认是关。 工具必须被显式列进 rules 才会开——安全默认值。前后端共用这个工厂,平台差异只从 platformFilter 这一个口子注入。

ToolArgumentsRepairer(engine/tools/ToolArgumentsRepairer.ts:56)——修模型吐出来的坏 JSON,两级:

  • 一级:截断。 流被打断时 JSON.parse 失败,退回 partial-json 尽量抢救出已完成的字段(:19-30)。
  • 二级:转义错乱。 某些模型(注释点名 Claude haiku-4.5)会把整串参数塞进第一个字段并转义引号,变成 { description: 'real desc", "instruction": "real instruction"}'。修法很土但有效:检测值里是否含 ", "缺失字段": 的模式,若有就重建 {"key": "值 再 parse(:106-131),然后校验所有 required 字段是否都齐了,齐了才采信,否则退回原样。

7.4 两级工具集:operation 级 vs step 级

工具不是一次定死的,agent 跑到一半可能激活新工具(接入设备、用户 @ 提到、模型自己发现)。所以工具集分两层:

OperationToolSet 创建 operation 时定下的快照(不可变)
+
StepToolDelta 本步的声明式增删 buildStepToolDelta.ts:38
+
accumulatedActivations 前面各步累积激活的

▼ ToolResolver.resolve() ToolResolver.ts:28
ResolvedToolSet 本次 call_llm 真正用的工具集

buildStepToolDelta(engine/tools/buildStepToolDelta.ts:38)把三种激活信号收敛成一个声明式对象:设备激活 → 注入 local-system manifest;@tool 提及 → 按 id 激活;forceFinishdeactivatedToolIds = ['*']。函数注释(:33-37)写明了目的:所有步级工具激活逻辑集中在这里,让 call_llm 执行器里没有零散的工具注入代码。

ToolResolver 里有两个细节:

  • manifestMap 只保留 enabledToolIds 里的条目(:39-46)。原因写在注释里:否则会给已禁用的工具注入 systemRole(比如搜索关了,web-browsing 的提示词还在)。
  • 激活时若 sourceMap 已有该工具的来源就不覆盖(:104-109),避免把 'discovery' 盖掉真正的路由来源(如 'composio')。

真实调用点在服务端执行器装配层 apps/server/src/modules/AgentRuntime/adapters/serverCallLlmTooling.ts:47(上游把原 RuntimeExecutors 单体重构进了 packages/agent-runtime;ForceFinishSummaryInjector 的接线在 packages/context-engine/src/engine/messages/MessagesEngine.ts:563)。

ManifestLoader(engine/tools/ManifestLoader.ts:10)只是一个接口——当步级激活引用了 operation 快照里没有的工具时,由实现方去数据库或市场按需拉 manifest。包内不含实现。


8. 技能装配与话题引用

8.1 技能:结构和工具完全对称

工具侧技能侧职责
ToolsEngineSkillEngine(engine/skills/SkillEngine.ts:15)从所有来源收集 + enableChecker 过滤 → operation 级集合
ToolResolverSkillResolver(engine/skills/SkillResolver.ts:22)合并 operation 级与步级激活 → 本次调用的集合
buildStepToolDeltabuildStepSkillDelta(engine/skills/buildStepSkillDelta.ts:15)从运行时信号构造声明式增量

有意思的是 buildStepSkillDelta 目前返回空 delta,函数注释直说它是"为未来的步级信号(如 @skill 提及)预留的扩展点"。这是把对称性先摆好、实现留白的写法。

SkillResolver.resolve()(:30)的合并规则:一个技能只要在 operation 级启用在任一步被激活过,就标记 activated: true;步级 delta 携带的 content覆盖原 content(:59)。下游 SkillContextProvider 据此决定注入全文还是只列个名字。

8.2 话题引用:resolveTopicReferences

用户可以在消息里 @ 引用另一个话题,写成 <refer_topic id="..." name="..." />。解析在 engine/topicReference/resolveTopicReferences.ts:35(parseReferTopicTags),有个容易忽略的细节:先把 markdown 转义反斜杠去掉(\<<)再匹配,否则编辑器转义过的标签会漏。

拿到 id 后的降级策略(:76resolveTopicReferences):

优先: 该话题的 historySummary(压缩摘要,最省 token)
↓ 没有
兜底: 拉最近 5 条 user/assistant 消息,每条截断到 300 字符
↓ 还没有
放弃这条引用

兜底路径有一处防御性 filter 值得记(:104-107 注释):历史消息的 content 可能不是字符串(多模态数组、null 的 tool 轮次),直接 .trim() 会抛 e.trim is not a function炸掉整个引擎,所以先 typeof m.content === 'string' 过一遍。

这个函数被设计成同时吃同步(客户端 store)和异步(服务端 DB)数据源——两边都传 lookupTopic / lookupMessages 回调进去。


9. Token 会计与压缩阈值

这节讲什么: 什么时候该把历史压缩掉?判断依据从哪来。

9.1 按来源分类计数

countContextTokens(tokenAccounting/index.ts:122)不只给一个总数,而是拆成六个来源桶(tokenAccounting/types.ts:22-28):

来源对应字段为什么要算
contentmsg.content正文
toolCallsmsg.tools[] 的 id/apiName/arguments/type会转成 tool_calls 发出去
thoughtSignaturemsg.tools[N].thoughtSignatureGemini 专有,每次函数调用都要回传,体积可观
reasoningmsg.reasoning.content思考模型会把它回灌进下一轮输入
toolCallIdmsg.tool_call_id小但真实存在
toolDefinition顶层 tools[]工具定义本身就很占地方

同样重要的是"不算什么":pluginpluginStateeditorDatafileListchunksList 等都是只存库不发送的字段,函数注释(:54-61)明说算了它们会高估、导致过早触发压缩

9.2 两个补偿系数

  • drift multiplier(默认 1.25,:14):底层用 tokenx 启发式估算,英文文本约 96% 准,但 agent 对话里 JSON/代码/中英混排多,通常低估 10–15%。1.25 既补这个偏差,又留约 10% 余量,让压缩比上游 tokenizer 触顶更早发生。
  • assistant 快路径(:109-119):如果 assistant 消息带着供应商回报的 usage.totalOutputTokens,直接用真实数字替代对该条的逐字段估算——那个数已经覆盖了 content + tool_calls + reasoning。

9.3 阈值判定

判定逻辑在 agent-runtime 侧(packages/agent-runtime/src/utils/tokenCounter.ts):

// packages/agent-runtime/src/utils/tokenCounter.ts:35-39, 81
threshold = Math.floor(maxWindowToken * thresholdRatio); // 默认 128_000 × 0.5
needsCompression = accounting.adjustedTotal > threshold; // 注意用的是 adjusted 不是 raw

默认在上下文窗口一半就触发压缩,而且比的是放大后的 adjustedTotal。返回给调用方的 currentTokenCount 却是未放大的 rawTotal(:80)——给人看的是原始估算,做决策的是保守估算。


10. 两处真实装配现场:客户端 vs 服务端

这节讲什么: 同一个 MessagesEngine,在浏览器和服务端各被 new 一次;流水线组成完全相同,差别全在喂进去的参数上。

全仓只有两处非测试调用点:

  • 客户端:src/services/chat/mecha/contextEngineering.ts:703
  • 服务端:apps/server/src/modules/Mecha/ContextEngineering/index.ts:157

10.1 结构差异:拉 vs 推

客户端 contextEngineering.ts 服务端 ContextEngineering/index.ts
│ │
│ 自己去 store / service 里【拉】数据 │ 一切数据由调用方【推】进参数
│ getChatStoreState() / getAgentStoreState() │ ServerMessagesEngineParams
│ agentService / notebookService / trpc │ (纯函数,无全局状态)
│ ~660 行的收集逻辑 │ ~130 行的转发逻辑
▼ ▼
new MessagesEngine({ ... }) ← 同一个类,同一条流水线

客户端那 660 行里在干什么:查群组详情、解析计划与 todo、组装用户记忆、解析话题引用(带 store + message service 双回调)、拉 onboarding 上下文、解析可用技能。服务端则假定这些都由上游算好传进来。

10.2 参数差异表

参数客户端服务端影响到哪个处理器
forceFinish不传ForceFinishSummaryInjector 实际只在服务端会触发
timezone不传(走默认 UTC)userTimezoneSystemDateProvider 的日期本地化
stepContext不传页面编辑器的分步 XML 快照
selectedSkills / selectedTools传(来自 initialContext)不传SelectedSkillInjector / SelectedToolInjector
planTodo传(从 store 组装,contextEngineering.ts:369)不传PlanInjector / TodoInjector
topicReferences自己解析(:621resolveTopicReferences)由调用方传入TopicReferenceContextInjector
fileContext.includeFileUrl!isDesktoptrueMessageContentProcessor 的附件 URL 拼接
variableGenerators基础变量 + 十余个惰性读 store 的生成器纯函数 + additionalVariablesPlaceholderVariablesProcessor
toolsConfig.disabledToolIdentifiers未含 page-agent 时禁用它同逻辑,但允许调用方覆盖DisabledToolCallFilter

客户端 variableGenerators 里那批惰性生成器很能说明设计意图(contextEngineering.ts:789-815):agent_idtopic_titlesandbox_uploaded_files 等都写成 () => ... 而不是先算好的值,注释明说是"只有占位符真的出现在渲染的消息里时才付这个代价"。

10.3 为什么客户端不传 forceFinish

代码里看不出显式说明。可确认的事实是:buildStepToolDelta 的调用点在服务端装配层(serverCallLlmTooling.ts:47)、ForceFinishSummaryInjector 在服务端消息引擎接线(MessagesEngine.ts:563),而客户端的 contextEngineering.ts 参数列表里没有这个字段。步数上限的强制收尾属于服务端 agent runtime 的职责 (inferred)。


11. 巧妙之处(可借鉴的技术)

  1. 顺序即配置。 处理器契约只有 name + process,没有优先级/依赖声明。全部排序知识集中在 MessagesEngine.buildProcessors()(MessagesEngine.ts:145)一个函数里,并用 Phase 注释标出为什么是这个顺序。要理解装配逻辑,读一个函数就够了。

  2. 注入插槽被抽象成四个基类,而不是四十个 if。 新增一种上下文,只需选一个基类、实现 buildContent(),合并/包裹/去重全部免费继承(base/ 全家)。

  3. SYSTEM CONTEXT 包裹只创建一次,后来者往里插。 insertIntoExistingWrapper()(base/BaseLastUserContentProvider.ts:51)让 N 个注入器共享一个包裹和一段 context.instruction,而不是叠 N 层。

  4. 高频内容往尾巴上挂,保护前缀缓存。 BaseVirtualLastUserContentProvider 的整个存在理由(:9-19)。

  5. 工具配对不变式靠"补一条合成失败"来保,而不是靠"删一半"。 ToolMessageReorder.ts:164-169

  6. 工具名的白名单反解。 resolve() 的兜底匹配被限制在本轮真的发出去的工具名集合内(ToolNameResolver.ts:167),把一个"模型猜名字"的便利功能挡在了越权之外。

  7. 按 UI 分组截断,而不是按条截断。 getSlicedMessages()(HistoryTruncate.ts:222)复刻了前端的分组逻辑,保证不会砍出孤儿 tool 消息。

  8. token 会计明确列出"不算什么"并给出理由。 tokenAccounting/index.ts:75-82 那段注释,比大多数项目的整份设计文档都有用。

  9. 失败降级而非失败中断。 PlaceholderVariables 单条失败继续跑并把诊断挂进 metadata(:381-390);InputTemplate 编译失败就整体跳过(:77-80)。上下文装配宁可少注入一点,也不该让用户发不出消息。


12. 边界与局限

  • 同步顺序,无并发。 pipeline.ts:85 是一个 for await 串行循环。若某个 provider 要发网络请求(如按需拉文档),整条流水线被它阻塞;包里没有并行组或依赖图。

  • cloneContext() 是浅拷贝。 只新建 messages 数组和 metadata 对象(base/BaseProcessor.ts:56-62),消息元素仍是共享引用。全靠所有处理器自觉用 {...msg} 展开写。哪个处理器原地改了字段,污染会往前传到 initialState 指向的同一批对象。

  • markAsExecuted() 是空实现。 base/BaseProcessor.ts:82-84 直接 return context。它保留了钩子形状但没有行为,读代码时容易误以为有执行追踪。

  • ToolResolver.mapSource() 所有分支都返回 'builtin' engine/tools/ToolResolver.ts:147-161 的 switch 四个分支同一个返回值——结构留好了,区分还没做。

  • buildStepSkillDelta() 永远返回空。 技能侧的步级激活目前只走 accumulatedActivations,delta 通道是空壳。

  • token 计数是启发式估算,不是真 tokenizer。 tokenx + 1.25 系数,注释自己承认 JSON/代码/中日韩混排场景偏差 10–15%。要精确计费不能用它。

  • MessagesEngine 的流水线是硬编码数组,不能外部重排。 ContextEngine 本身支持 addProcessor / removeProcessor,但 MessagesEngine 把处理器数组写死在方法里,调用方只能靠传参开关各个处理器,不能插入自定义处理器。


13. 横向对比

同 shelf 的相邻章节:


14. 代码地图(导航索引)

路径均相对克隆根;packages/context-engine/src/ 简写为 ce/

主题文件关键符号
流水线执行器ce/pipeline.tsContextEngineprocessvalidate
核心类型与错误ce/types.tsPipelineContextContextProcessorPipelineResultProcessorErrorPipelineError
处理器模板方法ce/base/BaseProcessor.tsBaseProcessordoProcesscloneContextabort
system 插槽ce/base/BaseSystemRoleProvider.tsBaseSystemRoleProviderbuildSystemRoleContentonInjected
首 user 前插槽ce/base/BaseFirstUserContentProvider.tsBaseFirstUserContentProviderSYSTEM_INJECTION_MARKER
末 user 后插槽ce/base/BaseLastUserContentProvider.tsBaseLastUserContentProviderwrapWithSystemContextinsertIntoExistingWrapper
逐条 user 插槽ce/base/BaseEveryUserContentProvider.tsBaseEveryUserContentProviderbuildContentForMessage
虚拟尾部插槽ce/base/BaseVirtualLastUserContentProvider.tsBaseVirtualLastUserContentProviderVIRTUAL_LAST_USER_MARKER
注入包裹标记ce/base/constants.tsSYSTEM_CONTEXT_STARTCONTEXT_INSTRUCTION
默认流水线定义ce/engine/messages/MessagesEngine.tsMessagesEnginebuildProcessors
分组感知截断ce/processors/HistoryTruncate.tsgetSlicedMessagesisGroupStartcollectAssistantGroupIds
工具消息配对修复ce/processors/ToolMessageReorder.tsToolMessageReorderreorderToolMessagesDEFAULT_TOOL_FAILURE_CONTENT
历史工具调用过滤ce/processors/DisabledToolCallFilter.tsDisabledToolCallFilterisDisabledToolName
多模态与视觉降级ce/processors/MessageContent.tsMessageContentProcessorVISION_DOWNGRADE_PLACEHOLDER
最终字段清洗ce/processors/MessageCleanup.tsMessageCleanupProcessorcleanMessage
变量渲染ce/processors/PlaceholderVariables.tsPlaceholderVariablesProcessorparsePlaceholderVariablesrenderPlaceholderTemplate
输入模板ce/processors/InputTemplate.tsInputTemplateProcessor
群聊角色改写ce/processors/GroupRoleTransform.tsGroupRoleTransformProcessortransformAssistantMessage
群聊卡片展平ce/processors/GroupMessageFlatten.tsce/processors/AgentCouncilFlatten.tsGroupMessageFlattenProcessorAgentCouncilFlattenProcessorflattenAssistantGroup
编排噪声过滤ce/processors/GroupOrchestrationFilter.tsGroupOrchestrationFilterProcessor
任务卡片处理ce/processors/TaskMessage.tsTasksFlatten.tsTaskCallbackMessage.tsVerifyMessage.tsTaskMessageProcessorTasksFlattenProcessorTaskCallbackMessageProcessorVerifyMessageProcessor
system 注入器ce/providers/SystemRoleInjector.tsSystemDateProvider.tsToolSystemRole.tsSkillContextProvider.tsHistorySummary.tsSystemRoleInjectorSystemDateProviderToolSystemRoleProviderSkillContextProviderHistorySummaryProvider
首 user 前注入器ce/providers/KnowledgeInjector.tsUserMemoryInjector.tsPlanInjector.tsToolDiscoveryProvider.tsKnowledgeInjectorUserMemoryInjectorPlanInjectorToolDiscoveryProvider
末 user 注入器ce/providers/TodoInjector.tsSelectedToolInjector.tsSelectedSkillInjector.tsTodoInjectorSelectedToolInjectorSelectedSkillInjector
强制收尾ce/providers/ForceFinishSummaryInjector.tsForceFinishSummaryInjector
Agent 文档五落点ce/providers/AgentDocumentInjector/AGENT_DOCUMENT_INJECTION_POSITIONSfilterDocumentsByRules
工具清单装配ce/engine/tools/ToolsEngine.tsToolsEnginegenerateToolsgenerateToolsDetailedfilterEnabledPlugins
工具名编解码ce/engine/tools/ToolNameResolver.tsToolNameResolvergenerateresolvenormalizeComponent
工具参数归一化ce/engine/tools/utils.tsnormalizeToolParametersgenerateToolsFromManifestvalidateManifest
启用判定工厂ce/engine/tools/enableCheckerFactory.tscreateEnableChecker
步级工具增量ce/engine/tools/buildStepToolDelta.tsToolResolver.tsbuildStepToolDeltaToolResolver.resolve
坏参数修复ce/engine/tools/ToolArgumentsRepairer.tsToolArgumentsRepairerrepair
按需 manifest 接口ce/engine/tools/ManifestLoader.tsManifestLoader
技能装配ce/engine/skills/SkillEngineSkillResolverbuildStepSkillDelta
话题引用解析ce/engine/topicReference/resolveTopicReferences.tsparseReferTopicTagsresolveTopicReferences
token 会计ce/tokenAccounting/index.tscountContextTokensDEFAULT_DRIFT_MULTIPLIER
压缩阈值判定packages/agent-runtime/src/utils/tokenCounter.tsshouldCompressgetCompressionThreshold
客户端装配现场src/services/chat/mecha/contextEngineering.tsnew MessagesEngine(:667)
服务端装配现场apps/server/src/modules/Mecha/ContextEngineering/index.tsserverMessagesEngine(:84)