AI Agent 工程化系列第三篇。本文以 Cherry Studio 的运行时为例,讲解对话历史、附件、知识库、记忆与 Agent Session 如何共同构成模型上下文,并给出关键算法的伪代码与工程取舍。
写在前面:不是上下文越长,Agent 就越聪明
做 Chat 时,最省事的做法是把历史消息带上;做 Agent Work 后,这招很快就不够用了。附件、知识库、工具结果、长期记忆、工作区状态都想进来,模型上下文像一个不断往里塞东西的背包。
背包大不代表好用:无关资料会稀释重点,长文本会吞掉 token,跨用户或跨工作区的数据更不能"顺手带上"。
因此,团队在研究 Cherry Studio 时,重点不是"它能塞多少上下文",而是"它如何让模型在这一步只看到该看的事实"。这正是 Context Engineering 要解决的问题。
1. Context Engineering 解决什么问题
模型输出的质量上限由当前可见上下文决定。
text
模型输出 = f(System Prompt, 消息历史, 附件, 工具结果, 检索资料, 当前运行时状态)
因此 Context Engineering 的问题不是"如何塞进更多信息",而是:
- 哪些信息是当前任务真正需要的?
- 信息以什么形式进入模型:原文件、提取文本、摘要、检索结果还是工具句柄?
- 如何控制 token、延迟、隐私和噪声?
- 如何确保模型能回到原始证据,而不是根据片段猜测?
- 用户继续对话、切换分支、插话或审批后,上下文如何保持一致?
一个实用的目标函数是:
text
最大化:任务所需证据的覆盖率与可信度
最小化:无关 token、过期事实、敏感数据暴露和上下文冲突
Prompt Engineering 定义"模型应怎样做";Context Engineering 决定"模型此刻根据什么做"。
2. 回到 Cherry Studio:先把上下文拆开
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 |
这意味着"上下文"不是单一模块,而是一组数据选择与表示算法。先分层,再决定每层何时进场,能避免所有信息在一开始就挤进同一个请求。
3. 关键算法一:别把整棵消息树都塞进去
3.1 为什么历史不是简单 append
持久化 Chat 支持 regenerate 和多分支回复。用户可以从某条历史消息继续,产生新分支;因此一个 topic 内的全部消息不能都发送给模型。
正确的历史应是:
text
根节点 → 当前 anchor 的唯一路径
而不是:
text
该 topic 的全部消息
Cherry Studio 的 PersistentChatContextProvider.buildHistory 按 anchor 回溯路径,再应用"清除上下文"边界。
伪代码:
ts
function buildHistory(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
}))
}
该算法复杂度与当前分支路径长度成正比:
text
时间复杂度:O(depth)
空间复杂度:O(depth)
其中 depth 是根节点到 anchor 的消息数量,通常远小于 topic 中全部消息数量。
3.2 clear-context 为什么是逻辑边界
清除上下文并不删除数据库记录。原消息仍可展示、搜索、审计和作为分支历史存在;只是后续模型调用不再看到它之前的内容。
text
M1 → A1 → M2[clear-context] → A2 → M3
模型看到:A2 → M3
数据库仍保存:M1 → A1 → M2 → A2 → M3
这个设计避免了两类问题:
- 用户希望"重新开始讨论",却丢失可见聊天记录。
- 通过物理删除实现压缩,导致回溯、审计与分支关系损坏。
3.3 当前边界:显式清除,不是自动压缩
普通持久化 Chat 目前以明确的 clear-context part 决定历史边界,并不会依据模型上下文窗口自动摘要或裁剪整段历史。
这是一个刻意需要产品决策的边界。自动压缩虽然能避免超窗,却可能丢失工具状态、审批结果、用户约束和引用来源。若未来引入自动压缩,应把它建模为一条可持久化、可检查、可重放的摘要消息,而不是请求前临时修改历史。
4. 关键算法二:根据模型能力路由附件
同一附件不应以固定方式进入每个模型。例如,视觉模型应接收原图;文本模型则需要 OCR 文本。Cherry Studio 的原则是:
每个附件根据 Provider 与模型能力,选择原生输入或提取文本;模型无需调用工具也能看到附件基本内容。
4.1 附件路由决策
ts
function prepareAttachment(part, modelCapabilities, requestContext) {
if (!part.hasFirstPartyFileEntry) {
// 外部/gateway 文件保持兼容的原始处理路径
return materializeOrReadableFailureNote(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)
return inlineWithCap(part.modelFacingHandle, text, requestContext)
}
isNativeSupported 的概念逻辑:
ts
function isNativeSupported(fileType, capabilities) {
switch (fileType) {
case "image": return capabilities.vision
case "audio": return capabilities.audio
case "video": return capabilities.video
case "pdf": return capabilities.nativePdf
default: return false
}
}
非原生文件的降级路径:
| 文件类型 | 非原生模型看到的内容 |
|---|---|
| 图片 | OCR 文本 |
| PDF / Office / 文本 / 代码 | 提取文本 |
| 音频 / 视频 | 明确的不支持提示 |
| 二进制文件 | 明确的不支持提示 |
| 读取或解析失败 | could not read this file 提示 |
这里的关键不是"尽量解析一切",而是永远不要静默丢掉附件。模型必须知道某个附件存在但当前不可读,否则它会基于缺失信息给出过度自信的结论。
4.2 为什么原生文件优先
将原生图片或 PDF 总是先 OCR 抽取为文本,会损失布局、表格、图形和多模态信息;同时会让模型能力向低能力 Provider 退化。
反过来,完全只提供 read_file 工具也不正确:非工具模型或未主动调用工具的模型会完全看不到附件内容。
正确策略是:
text
模型原生支持 → 发送原文件
模型不原生支持 → 主动提供可读文本
文本过长 → 内联前段 + 提供受限分页工具
无法读取 → 明确说明失败
5. 关键算法三:截断、分页与安全文件句柄
附件全文可能很大。如果不加控制,单个 PDF 就足以挤掉整个对话历史;如果简单截断,又会让模型无法获得后半部分内容。
Cherry Studio 使用"内联首段 + 按需分页"的混合策略。
ts
function capInlineText(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]`
}
5.1 为什么需要 surrogateSafeEnd
JavaScript 字符串以 UTF-16 code unit 计数。若直接在 cap 位置 slice,可能切断一个代理对,例如 emoji 或部分非 BMP 字符,产生非法或显示异常文本。
概念算法:
ts
function surrogateSafeEnd(text, requestedEnd) {
end = min(requestedEnd, text.length)
if (isHighSurrogate(text[end - 1]) && isLowSurrogate(text[end])) {
return end - 1
}
return end
}
这是一类很小但重要的 Context Engineering 细节:错误截断不会只影响显示,还可能改变模型对结构化数据、路径或代码片段的理解。
5.2 模型可见的不是内部文件 ID
read_file 使用模型可见文件名作为 handle,而不是暴露 fileEntryId。同名附件需要生成稳定且唯一的别名:
ts
function uniqueHandle(displayName, used) {
base = trim(displayName) || "file"
candidate = base
suffix = 2
while (used.has(candidate)) {
candidate = `${base} (${suffix})`
suffix += 1
}
used.add(candidate)
return candidate
}
随后只允许模型在本轮附件 allow-list 内解析 handle:
ts
function readFileForModel(handle, offset, attachmentAllowList) {
ref = attachmentAllowList.find(item => item.handle === handle)
if (!ref) {
throw sanitizedError("Attached file not found")
}
return pageExtractedText(ref.fileEntryId, offset)
}
这个设计同时实现:
- 防止模型猜测或枚举内部文件 ID。
- 防止本轮模型读取未附加的用户文件。
- 让错误信息保持在用户可理解的文件名层,而不泄露内部路径和存储结构。
6. 关键算法四:知识库 Scope 的安全收窄
知识库 Context 不应通过"把所有库都搜索一遍"实现。Agent 需要先有可验证的可见范围。
Cherry Studio 将静态绑定视为 ceiling,而不是每轮选择的默认值:
ts
function resolveKnowledgeBaseScope(configuredIds, selectedIds) {
if (configuredIds is empty) {
return canonicalize(selectedIds)
}
if (selectedIds is empty) {
return canonicalize(configuredIds)
}
configured = new Set(configuredIds)
narrowed = selectedIds.filter(id => configured.has(id))
if (narrowed is empty) {
// 全部选择越界时,保留原静态绑定,避免无意禁用 Agent 知识
return canonicalize(configuredIds)
}
return canonicalize(narrowed)
}
function canonicalize(ids) {
return sort(unique(ids))
}
这段逻辑有三个语义:
- 静态绑定是权限上限:Renderer 传来的 ID 不能扩大 Agent 可见库。
- 本轮选择是收窄器:用户可以临时只让 Agent 使用某几个已绑定库。
- 输出规范化:去重与排序使 scope 可比较、可缓存并可作为运行时重建签名。
需要注意不同运行时的空 scope 语义。共享检索核心中空 allow-list 可以表示"不额外限制";但 Agent Session 会在没有有效 scope 时隐藏或拒绝知识库工具,采用 fail-closed 策略。产品层必须明确"默认允许搜索全部个人库"还是"必须显式选择知识库",不能把这一差异留给调用方猜测。
7. 关键算法五:Agentic RAG 的渐进式取证
RAG 不是把检索结果自动附在每次请求后。对于知识库问答,Cherry Studio 提供面向模型的逐步工具:
text
kb_list → kb_search → kb_read
可将其理解为逐层缩小搜索空间:
ts
async function answerWithKnowledge(question, allowedBaseIds) {
bases = await kb_list({ allowedBaseIds })
candidates = chooseRelevantBases(question, bases)
hits = await kb_search({
baseIds: candidates,
query: question
})
evidence = await kb_read({
baseId: hits[0].baseId,
conceptId: hits[0].conceptId
})
return generateAnswer(question, evidence, citationsFrom(hits))
}
这不是要求模型机械地执行固定链路,而是表达三种不同粒度的信息访问:
| 工具 | 信息粒度 | 适用问题 |
|---|---|---|
kb_list |
知识库与资料目录 | "哪些资料可能有关?" |
kb_search |
相关 chunk | "哪些片段支持当前问题?" |
kb_read |
文档全文或指定页/grep | "片段是否被断章取义?需要精确引用什么?" |
它比"检索 topK 后直接回答"更可靠,因为模型可以在证据不足时主动扩大或深入查询;同时比"整库加入上下文"更节省 token。
8. Agent Session 与普通 Chat 的 Context 差异
普通 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:
ts
function handleLiveFollowUp(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 中间。
9. Context 预算:应该控制什么
Context 预算不应只看 token 总数。至少需要分别观察:
text
预算 = 历史消息 + Prompt + 附件文本 + 工具定义 + 工具结果 + RAG 证据 + 模型预留输出
其中风险最大的通常不是用户一句话,而是:
- 多轮历史中重复出现的附件文本。
- 长文件提取内容。
- 大型 MCP 的工具定义。
- 未裁剪的工具返回。
- 大量检索 chunk 或完整网页正文。
建议为每类 Context 设置不同策略:
| 类型 | 优先策略 |
|---|---|
| 系统策略 | 静态化、缓存、版本化 |
| 历史消息 | 分支选择、显式 clear、未来可持久化压缩 |
| 附件 | 原生优先、提取文本 cap、按需分页 |
| 知识库 | list/search/read 渐进检索、引用保留 |
| 工具结果 | 结构化、限长、保留可继续读取的句柄 |
| 记忆 | 长期事实内联,事件日志按需检索 |
任何压缩算法都应保留以下信息:
- 用户的硬约束和未完成任务。
- 工具执行状态与审批状态。
- 关键实体、文件名、路径、引用和错误。
- 能回到原始材料的定位符。
10. 这些 Context 坑,最好提前绕开
反模式:将整个会话、所有附件和所有资料直接发送
后果是 token 爆炸、注意力稀释和敏感信息暴露。应改为分支选择、cap、分页和渐进检索。
反模式:把附件可见性完全交给工具调用
弱模型或未调用工具的模型会看不到附件。应至少内联受控的可读内容或不可读提示。
反模式:仅在 UI 层限制知识库选择
Renderer 输入不可信。必须在主进程根据静态绑定重新计算 scope。
反模式:截断后不告诉模型还有内容
模型会把前半段误认为全文。对于工具模型,应提供包含 offset 的 read_file 提示。
反模式:让自动摘要悄悄改写会话事实
摘要一旦取代原历史却不可追溯,用户无法验证信息是否丢失。应持久化摘要、记录其覆盖范围,并保留回读原消息的能力。
11. 小结
Context Engineering 的成熟标志不是支持更多输入类型,而是能够稳定回答:
- 模型这一轮实际看到了什么?
- 为什么是这些内容,而不是更多或更少?
- 附件、资料和历史能否回到原始证据?
- 当上下文超出预算、用户插话或文件不可读时,系统如何降级?
- 这些规则是否在主进程和运行时边界被强制执行?
一句话总结:
好的 Context Engineering 不追求让模型知道一切,而是让模型在每个决策点都拿到足以完成任务、且可验证与可控的信息。