ByteFisher AI 工程化深度实战(三):上下文工程与结构化输出

第 02 篇我们建立了成本模型,发现两个成本大头:system prompt 里的候选文章历史对话的重复计费。本篇就从这两个大头下手,同时解决一个更本质的问题——怎么让模型”记得住”而不”记爆”

“上下文工程(Context Engineering)”在 2026 年已经是一个成熟的研究方向,核心观点:长上下文不是越多越好,上下文的质量取决于信息的组织方式,而非 token 数量。Anthropic 等团队的研究一再表明,在超长上下文中,模型对关键信息的提取能力会随无关信息增多而下降(俗称”大海捞针”难题)。所以工程化的核心不是”塞满”,而是”精选 + 分层 + 压缩”。

一、ByteBot 的上下文现状

ai-assistant.js 里的 truncateMessages() 是唯一的内存管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function truncateMessages(msgs) {
var maxTokens = 3000;
if (msgs.length <= 4) return msgs;

// 永远保留第一组 user + assistant
var head = msgs.slice(0, 2);
var tail = msgs.slice(2);

var result = [];
var total = head.reduce(function(s, m) { return s + estimateTokens(m.content); }, 0);
// 从尾部往前累计,超过 3000 就截断
for (var i = tail.length - 1; i >= 0; i--) {
var t = estimateTokens(tail[i].content);
if (total + t > maxTokens) break;
total += t;
result.unshift(tail[i]);
}
return head.concat(result);
}

这个方案的三个问题:

  1. “头部保底”逻辑有缺陷:第一组 user+assistant 永远保留,但对话到第 20 轮时,第 1 轮的信息早已和当前话题无关,白占 token。
  2. 截断即遗忘,无摘要:中间被截掉的消息完全消失,如果用户后来问”我们刚才聊了什么”,模型答不上来。
  3. system prompt 全文注入buildSystemPrompt() 每轮都塞 8 篇文章的完整摘要(每条 150 字符)+ 全部系列列表,这个长度远超历史消息,却不被 truncateMessages() 管理。

二、记忆分层架构

工程级的对话记忆应该分三层,ByteBot 目前只有一层(原始消息)。

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────────────┐
│ 短期记忆(当前轮) │
│ - 最新 2~4 轮原始消息 │
│ - 直接进 prompt,保真度最高 │
├──────────────────────────────────────────┤
│ 工作记忆(本话题) │
│ - 滚动摘要:从旧轮次提炼的紧凑摘要 │
│ - 压缩后进 prompt,占用固定 token 预算 │
├──────────────────────────────────────────┤
│ 长期记忆(跨会话) │
│ - 用户画像、事实性偏好、历史话题索引 │
│ - 存外部存储(TiDB/Redis),按需检索注入 │
└──────────────────────────────────────────┘

2.1 滚动摘要(Rolling Summary)——核心改造

核心思想:维护一个 topic_summary 字符串,每当对话超过阈值(比如 6 轮),调用一次模型把”最旧的一部分对话 + 现有摘要”压缩成新摘要,然后丢弃旧消息。

1
2
3
4
5
6
7
8
9
10
11
第 6 轮触发压缩:
inputs = 现有topic_summary + 第 1~4 轮原始消息
outputs = 新的topic_summary(约 200 token)

第 7 轮起,prompt 变成:
[system]
话题摘要:{topic_summary}
[user] 第 5 轮消息
[assistant] 第 5 轮回答
[user] 第 6 轮消息
...

ByteBot 落地方案:

1
2
3
4
5
6
7
8
async function compressHistory(messages, topicSummary, model) {
const system = '你是对话摘要器。请把【现有摘要】与【新对话】压缩为一段不超过200字的话题摘要,保留:用户的目标、关键事实、未完成的请求、已给过的推荐。不要添加新信息。';
const resp = await callModel(model, [
{ role: 'system', content: system },
{ role: 'user', content: `【现有摘要】\n${topicSummary}\n\n【新对话】\n${messages.map(m => `${m.role}: ${m.content}`).join('\n')}` }
], { max_tokens: 300, temperature: 0.3 });
return resp.content;
}

注意摘要器用低 temperature(0.3),因为摘要任务要确定性不要创造性。这个”摘要器”也是第 10 篇多 Agent 里”记录员 Agent”的原型。

2.2 记忆分层的 token 预算

给每轮请求一个固定预算,各层按优先级分配:

层级 预算占比 说明
系统角色指令 10% 人设 + 输出规则,精简后固定
话题摘要 15% 压缩后恒定的历史记忆
当前轮对话 25% 最近 2~4 轮原始消息
检索上下文 40% 候选文章,本系列第 04 篇重点优化
输出预留 10% max_tokens 之外的余量

这个预算表把第 02 篇的”成本大头”治理成了固定预算制:无论聊多久,每次请求的 token 消耗都被钳制在预算内——成本可控,质量也可控。

三、上下文压缩的三层技术

3.1 文本级:压缩 prompt 本身

  • 保留原则:摘要保留”信息密度高”的部分,删除寒暄、重复、细节。
  • 结构化替换:文章推荐从”完整标题+摘要”改为”ID 引用 + 模型需要时才展开”。比如 system prompt 里写 @doc_17,回答生成时再映射回真实标题链接。ByteBot 的 validateLinks() 已经强制链接必须存在于索引,这个机制可以和引用压缩天然结合。

3.2 语义级:检索相关性截断

注入 context 时按”与当前问题相关性”排序,只取前 N 条(ByteBot 是 8 条),且每条只保留命中片段而不是全文摘要。这是第 04 篇 RAG 的内容,但它是上下文压缩的组成部分——检索不是把数据库搬进 prompt,而是把最相关的证据搬进去

3.3 模型级:用强模型摘要、弱模型执行

摘要任务(信息密度高、token 敏感)用强模型(或 reasoner),执行任务用快模型。离线流水线(钓鱼日记)里这个分工尤其明显——第 10 篇会展开。

四、结构化输出:JSON Schema 与函数调用

ByteBot 现在最大的”脏活”全在前端:preLinkArticles() 把裸标题改写成链接、validateLinks() 校验并修正链接、norm() 归一化全半角、marked+DOMPurify 渲染 Markdown。这些函数存在,正是因为模型输出是不受控的字符串。

工程化的解法是让输出结构化——用 DeepSeek 支持的 response_format 或 function calling,让模型直接输出 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const body = {
model: 'deepseek-chat',
messages: outboundMessages,
tools: [{
type: 'function',
function: {
name: 'answer_blog_question',
description: '回答 ByteFisher 博客相关问题的结构化输出',
parameters: {
type: 'object',
properties: {
summary: { type: 'string', description: '2-4句话的答案总结' },
recommendations: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
url: { type: 'string', format: 'uri' },
reason: { type: 'string', description: '推荐原因,一句' }
},
required: ['title', 'url']
},
description: '推荐的文章,最多8篇,title必须与候选列表完全一致'
},
fun_pages: { type: 'array', items: { type: 'string' }, description: '可选功能页面路径' }
},
required: ['summary']
}
}
}],
tool_choice: { type: 'function', function: { name: 'answer_blog_question' } }
};

改造收益立竿见影:

对比项 改造前(自由文本) 改造后(JSON)
前端修正函数 preLink/validateLinks/norm 三件套 前端只需渲染,砍掉一半代码
链接幻觉 需后置校验拦截 Schema 里强制 title 来自候选列表
数据分析 无法结构化 直接进评测/统计(第 05/11 篇)
渲染安全 marked + DOMPurify 白名单渲染,风险大降

一个重要的工程细节:结构化输出必须配合”约束注入”。仅靠 JSON Schema 还不够,system prompt 里要同步声明”title 必须逐字取自候选列表,禁止改写”——模型往往在 JSON 模式下更守规矩,但双重约束更稳。

五、长上下文策略:ByteBot 需要吗?

ByteBot 是”导览型助手”,正确答案集中在站内内容,几乎不需要超长上下文。超长上下文(64K~200K token)对三类场景才有价值:代码仓库级理解、长文档问答、复杂推理链。

对不需要长上下文的场景,硬上长上下文有明确代价:

  • 成本上升:上下文按输入 token 计费,200K 上下文单次可能几毛钱。
  • 注意力稀释:长上下文里信息检索精度下降(”大海捞针”实验),反而变笨。
  • 延迟上升:prefill 变长,首 token 延迟增加。

正确的策略是”够用就好 + 按需外置”:需要长文档理解时,用 RAG 把相关片段拉进来,而不是把整本书塞进去。这个原则贯穿整个系列。

六、落地清单与小结

针对 ByteBot 的改造优先级:

优先级 改造 收益
P0 滚动摘要替代简单截断 多轮对话不丢失话题、token 恒定
P0 上下文固定预算制 成本可预测
P1 结构化输出(function calling) 干掉前端三件套、数据可分析
P2 长期记忆(用户画像存 TiDB) 跨会话个性化
P3 强摘要模型/快执行模型分工 质量和成本双优化

核心结论:

  • 上下文工程 = 精选 + 分层 + 压缩,不是”塞满窗口”。
  • 记忆分三层:短期保真、工作层滚动摘要、长期外置存储;ByteBot 从”单层截断”升级为”三层架构”。
  • 滚动摘要用低 temperature 的专用摘要器,这是多 Agent 里记录员 Agent 的原型。
  • 结构化输出是 AI 应用工程化的分水岭——从”调通模型”到”模型输出可直接进系统”,JSON Schema + 函数调用是必经之路。
  • 长上下文是有代价的,ByteBot 用 RAG 外置而非硬塞。

下一篇进入 RAG 深度优化(上):把 ByteBot 的单路 BM25 检索升级为混合检索 + 向量 + Rerank 的完整链路,用博客 260 篇文章做真实知识库。


本系列持续更新中,每周一篇。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: