作者:互联网 时间: 2026-09-01 18:44:55
Context Engineering:让 Agent 在当前步骤看到正确的事实并不只看表面做法,关键还要理解相关条件、限制和后续影响。
做 Chat 时,最省事的做法是把历史消息带上;做 Agent Work 后,这招很快就不够用了。附件、知识库、工具结果、长期记忆、工作区状态都想进来,模型上下文像一个不断往里塞东西的背包。
背包大不代表好用:无关资料会稀释重点,长文本会吞掉 token,跨用户或跨工作区的数据更不能“顺手带上”。

因此,团队在研究 Cherry Studio 时,重点不是“它能塞多少上下文”,而是“它如何让模型在这一步只看到该看的事实”。这正是 Context Engineering 要解决的问题。
模型输出的质量上限由当前可见上下文决定。
模型输出 = f(System Prompt, 消息历史, 附件, 工具结果, 检索资料, 当前运行时状态)
因此 Context Engineering 的问题不是“如何塞进更多信息”,而是:
一个实用的目标函数是:
最大化:任务所需证据的覆盖率与可信度最小化:无关 token、过期事实、敏感数据暴露和上下文冲突
Prompt Engineering 定义“模型应怎样做”;Context Engineering 决定“模型此刻根据什么做”。
Cherry Studio 不将所有数据混成一个长字符串,而是由不同层分别负责:
| Context 层 | 内容 | 主要来源 | 进入模型的方式 |
|---|---|---|---|
| System | 产品规则、Assistant 指令、工具策略 | Prompt 构造器 | system instructions |
| Conversation | 当前对话分支中的用户、助手与工具消息 | SQLite / 临时会话 | messages |
| Attachments | 文件、图片、PDF、音频、视频 | FileManager / FileProcessing | 原生文件或提取文本 |
| Knowledge | 私有知识库资料 | KnowledgeService | kb_* 按需工具结果 |
| Memory | 身份、偏好、长期事实、事件 | Agent 数据目录 | Prompt 回灌或 memory tool |
| Runtime | 工作区、语言、会话状态、resume token | Agent Session Runtime | settings / SDK context |
这意味着“上下文”不是单一模块,而是一组数据选择与表示算法。先分层,再决定每层何时进场,能避免所有信息在一开始就挤进同一个请求。
持久化 Chat 支持 regenerate 和多分支回复。用户可以从某条历史消息继续,产生新分支;因此一个 topic 内的全部消息不能都发送给模型。
正确的历史应是:
根节点 → 当前 anchor 的唯一路径
而不是:
该 topic 的全部消息
Cherry Studio 的 PersistentChatContextProvider.buildHistory 按 anchor 回溯路径,再应用“清除上下文”边界。
伪代码:
functionbuildHistory(anchorMessageId) {// 返回从根到 anchor 的单一路径,排除兄弟分支path = messageService.getPathToNode(anchorMessageId)// clear-context 是显式上下文边界,而不是删除历史数据lastClearIndex = findLastIndex(path, message =>message.parts.contains("clear-context"))visiblePath = path.slice(lastClearIndex + 1)return visiblePath.map(message => ({id: message.id,role: toContentRole(message.role),parts: message.parts}))}
该算法复杂度与当前分支路径长度成正比:
时间复杂度:O(depth)空间复杂度:O(depth)
其中 depth 是根节点到 anchor 的消息数量,通常远小于 topic 中全部消息数量。
清除上下文并不删除数据库记录。原消息仍可展示、搜索、审计和作为分支历史存在;只是后续模型调用不再看到它之前的内容。
M1 → A1 → M2[clear-context] → A2 → M3模型看到:A2 → M3数据库仍保存:M1 → A1 → M2 → A2 → M3
这个设计避免了两类问题:
普通持久化 Chat 目前以明确的 clear-context part 决定历史边界,并不会依据模型上下文窗口自动摘要或裁剪整段历史。
这是一个刻意需要产品决策的边界。自动压缩虽然能避免超窗,却可能丢失工具状态、审批结果、用户约束和引用来源。若未来引入自动压缩,应把它建模为一条可持久化、可检查、可重放的摘要消息,而不是请求前临时修改历史。
同一附件不应以固定方式进入每个模型。例如,视觉模型应接收原图;文本模型则需要 OCR 文本。Cherry Studio 的原则是:
functionprepareAttachment(part, modelCapabilities, requestContext) {if (!part.hasFirstPartyFileEntry) {// 外部/gateway 文件保持兼容的原始处理路径returnmaterializeOrReadableFailureNote(part)}file = fileManager.getById(part.fileEntryId)type = classify(file.extension)if (isNativeSupported(type, modelCapabilities)) {native = materializeNativeFilePart(part)return native ?? unreadableFileNote(part.filename)}text = extractByType(type, file)returninlineWithCap(part.modelFacingHandle, text, requestContext)}
isNativeSupported 的概念逻辑:
functionisNativeSupported(fileType, capabilities) {switch (fileType) {case"image": return capabilities.visioncase"audio": return capabilities.audiocase"video": return capabilities.videocase"pdf": return capabilities.nativePdfdefault: returnfalse}}
非原生文件的降级路径:
| 文件类型 | 非原生模型看到的内容 |
|---|---|
| 图片 | OCR 文本 |
| PDF / Office / 文本 / 代码 | 提取文本 |
| 音频 / 视频 | 明确的不支持提示 |
| 二进制文件 | 明确的不支持提示 |
| 读取或解析失败 | could not read this file 提示 |
这里的关键不是“尽量解析一切”,而是永远不要静默丢掉附件。模型必须知道某个附件存在但当前不可读,否则它会基于缺失信息给出过度自信的结论。
将原生图片或 PDF 总是先 OCR 抽取为文本,会损失布局、表格、图形和多模态信息;同时会让模型能力向低能力 Provider 退化。
反过来,完全只提供 read_file 工具也不正确:非工具模型或未主动调用工具的模型会完全看不到附件内容。
正确策略是:
模型原生支持 → 发送原文件模型不原生支持 → 主动提供可读文本文本过长 → 内联前段 + 提供受限分页工具无法读取 → 明确说明失败
附件全文可能很大。如果不加控制,单个 PDF 就足以挤掉整个对话历史;如果简单截断,又会让模型无法获得后半部分内容。
Cherry Studio 使用“内联首段 + 按需分页”的混合策略。
functioncapInlineText(handle, text, isToolCapable, cap) {if (text.length <= cap) {return text}end = surrogateSafeEnd(text, cap)head = text.slice(0, end)if (!isToolCapable) {return head + `[truncated ${end}/${text.length} chars]`}return head +`[truncated ${end}/${text.length} chars; ` +`call read_file("${handle}", offset=${end}) for more]`}
JavaScript 字符串以 UTF-16 code unit 计数。若直接在 cap 位置 slice,可能切断一个袋里对,例如 emoji 或部分非 BMP 字符,产生非法或显示异常文本。
概念算法:
functionsurrogateSafeEnd(text, requestedEnd) {end = min(requestedEnd, text.length)if (isHighSurrogate(text[end - 1]) && isLowSurrogate(text[end])) {return end - 1}return end}
这是一类很小但重要的 Context Engineering 细节:错误截断不会只影响显示,还可能改变模型对结构化数据、路径或代码片段的理解。
read_file 使用模型可见文件名作为 handle,而不是暴露 fileEntryId。同名附件需要生成稳定且唯一的别名:
functionuniqueHandle(displayName, used) {base = trim(displayName) || "file"candidate = basesuffix = 2while (used.has(candidate)) {candidate = `${base} (${suffix})`suffix += 1}used.add(candidate)return candidate}
随后只允许模型在本轮附件 allow-list 内解析 handle:
functionreadFileForModel(handle, offset, attachmentAllowList) {ref = attachmentAllowList.find(item => item.handle === handle)if (!ref) {throwsanitizedError("Attached file not found")}returnpageExtractedText(ref.fileEntryId, offset)}
这个设计同时实现:
知识库 Context 不应通过“把所有库都搜索一遍”实现。Agent 需要先有可验证的可见范围。
Cherry Studio 将静态绑定视为 ceiling,而不是每轮选择的默认值:
functionresolveKnowledgeBaseScope(configuredIds, selectedIds) {if (configuredIds is empty) {returncanonicalize(selectedIds)}if (selectedIds is empty) {returncanonicalize(configuredIds)}configured = newSet(configuredIds)narrowed = selectedIds.filter(id => configured.has(id))if (narrowed is empty) {// 全部选择越界时,保留原静态绑定,避免无意禁用 Agent 知识returncanonicalize(configuredIds)}returncanonicalize(narrowed)}functioncanonicalize(ids) {returnsort(unique(ids))}
这段逻辑有三个语义:
需要注意不同运行时的空 scope 语义。共享检索核心中空 allow-list 可以表示“不额外限制”;但 Agent Session 会在没有有效 scope 时隐藏或拒绝知识库工具,采用 fail-closed 策略。产品层必须明确“默认允许搜索全部个人库”还是“必须显式选择知识库”,不能把这一差异留给调用方猜测。
RAG 不是把检索结果自动附在每次请求后。对于知识库问答,Cherry Studio 提供面向模型的逐步工具:
kb_list → kb_search → kb_read
可将其理解为逐层缩小搜索空间:
asyncfunctionanswerWithKnowledge(question, allowedBaseIds) {bases = awaitkb_list({ allowedBaseIds })candidates = chooseRelevantBases(question, bases)hits = awaitkb_search({baseIds: candidates,query: question})evidence = awaitkb_read({baseId: hits[0].baseId,conceptId: hits[0].conceptId})returngenerateAnswer(question, evidence, citationsFrom(hits))}
这不是要求模型机械地执行固定链路,而是表达三种不同粒度的信息访问:
| 工具 | 信息粒度 | 适用问题 |
|---|---|---|
kb_list | 知识库与资料目录 | “哪些资料可能有关?” |
kb_search | 相关 chunk | “哪些片段支持当前问题?” |
kb_read | 文档全文或指定页/grep | “片段是否被断章取义?需要精确引用什么?” |
它比“检索 topK 后直接回答”更可靠,因为模型可以在证据不足时主动扩大或深入查询;同时比“整库加入上下文”更节省 token。
普通 Chat 的 Context 核心是消息树路径;Agent Session 的 Context 还包含工作区、runtime connection 和可恢复会话状态。
| 维度 | 普通 Chat | Agent Session |
|---|---|---|
| 主上下文 | 当前 message-tree 分支 | 当前 turn + driver 会话状态 |
| 工作目录 | 不作为通用 Chat 语义 | session workspace 是 cwd |
| 用户插话 | 运行中的 turn yield 后启动 continuation | 可在下一次工具调用前注入 steer,或加入 pending queue |
| 恢复锚点 | 持久化消息历史 | 最新 assistant 的 opaque resume token |
| Prompt | Assistant prompt + 当前工具策略 | PromptBuilder + workspace + runtime + session policy |
Agent Session 中收到 live follow-up 时,不应该简单把新文本塞进正在进行的模型输入。正确流程取决于 Driver 是否支持 redirect:
functionhandleLiveFollowUp(session, message) {if (session.hasLiveTurn && session.driver.supportsRedirect) {session.driver.redirect({message,systemReminder: true})return}session.pendingTurns.push(message)scheduleNextTurnAfterCurrentCompletes(session)}
对于 Claude Code Driver,redirect 会暂存 steer,并在下一次 PreToolUse 时作为 additionalContext 注入。这样上下文变更发生在工具调用边界,而不是任意流式 token 中间。
Context 预算不应只看 token 总数。至少需要分别观察:
预算 = 历史消息 + Prompt + 附件文本 + 工具定义 + 工具结果 + RAG 证据 + 模型预留输出
其中风险最大的通常不是用户一句话,而是:
建议为每类 Context 设置不同策略:
| 类型 | 优先策略 |
|---|---|
| 系统策略 | 静态化、缓存、版本化 |
| 历史消息 | 分支选择、显式 clear、未来可持久化压缩 |
| 附件 | 原生优先、提取文本 cap、按需分页 |
| 知识库 | list/search/read 渐进检索、引用保留 |
| 工具结果 | 结构化、限长、保留可继续读取的句柄 |
| 记忆 | 长期事实内联,事件日志按需检索 |
任何压缩算法都应保留以下信息:
后果是 token 爆炸、注意力稀释和敏感信息暴露。应改为分支选择、cap、分页和渐进检索。
弱模型或未调用工具的模型会看不到附件。应至少内联受控的可读内容或不可读提示。
Renderer 输入不可信。必须在主进程根据静态绑定重新计算 scope。
模型会把前半段误认为全文。对于工具模型,应提供包含 offset 的 read_file 提示。
摘要一旦取代原历史却不可追溯,用户无法验证信息是否丢失。应持久化摘要、记录其覆盖范围,并保留回读原消息的能力。
Context Engineering 的成熟标志不是支持更多输入类型,而是能够稳定回答:
一句话总结:
src/main/ai/streamManager/context/PersistentChatContextProvider.tssrc/main/ai/messages/attachmentRouting.tssrc/main/ai/utils/knowledgeScope.ts