做 AI Agent 最翻车的场景往往不是第一轮而是第七轮、第十五轮之后。用户前面刚说“帮我梳理一下需求”后面追问一句“那你刚才说的方案A什么时候落地”Agent 却把方案A忘得干干净净——这不是模型能力不够而是多轮对话管理没做好。我最近在一个纪要助手的模拟项目 X 里把固定上下文窗口、滑动上下文窗口和摘要记忆三种多轮对话管理架构都从零实现了一遍并且把同一套逻辑跑到了鸿蒙设备上。这篇文章会把每一处源码、每一组性能对比数据和踩过的坑一次性讲清楚。对于正在做 Agent 应用、或者准备把 Agent 塞进鸿蒙平板、车机、智能家居的同学来说这份记录可以直接当选型参考。1. 为什么多轮对话管理是 AI Agent 的胜负手1.1 一个典型失忆现场大多数人第一次接触 Agent都会把多轮对话想简单把用户消息、助手回复、工具调用结果拼成一个数组追加到请求里发给模型不就行了但问题会在第二轮、第三轮开始出现。假设用户说“帮我写一个提醒明天上午九点提醒我开会”Agent 回复“好的已经设置”。这时候用户补一句“改成下午两点”Agent 如果只是简单拼接消息确实能正确理解。可要是用户隔了一小时后回来说“把那个会议相关的文件夹也挂上去”模型要关联的就不只是最近的一条消息而是整个会话里关于“会议”这个实体的分布信息。固定拼接的方案很快就露馅了。上下文一长模型对早期信息的召回能力断崖式下降再长一点请求直接超出上下文窗口上限轻则报错重则把系统提示词也截断。多轮对话管理的本质不是把聊天记录存下来而是解决三个问题该记什么、该忘什么、塞不下了怎么办。1.2 对话管理到底在管什么从工程视角看多轮对话管理至少要处理四个维度的数据系统提示词定义 Agent 的角色、行为边界、工具使用规则一般不允许被截断。用户消息每轮用户输入是任务目标的主要来源。助手回复Agent 的历史输出包含已经承诺过的事情、已经给出的结论。工具调用与结构化结果比如查询到的订单、生成的日程、写入的数据库记录这类消息往往很长但信息密度很高。任何一个维度处理不好都会产生“看起来在聊天实际已经失忆”的假象。这里的难点在于大模型的上下文窗口是硬约束而对话产生的信息总量会持续增长。所以多轮对话架构做的工作本质上是在“保留更多信息”和“维持可用的请求体积”之间找平衡。1.3 三种架构的定位差异目前业内最常见、也能在端侧设备上落地的多轮对话管理方案可以归为三类架构核心策略信息保留能力实现复杂度典型场景固定上下文窗口只保留最近的 N 轮或 N tokens弱早期信息直接丢弃最低简单问答、一次性任务滑动上下文窗口按 token 预算动态保留最近窗口头尾滚动中能保留更久但仍会丢失中间内容中工具调用、流程式任务摘要记忆定期把历史压缩成摘要配合最近消息共同形成 prompt强理论上可覆盖整个会话高角色扮演、长期任务、跨天会话下面我把三种架构的源码实现、边界条件和实测数据分开说。2. 架构一固定上下文窗口——最容易被低估的截断方案2.1 设计思路一句话版本与完整版本一句话版本维护一个消息数组每次新消息进来就往尾部追加然后从头开始删直到总 token 数低于预算。完整版本没这么简单。直接 shift 掉最早的消息会发现三个问题系统提示词被误删、单条超长消息把窗口塞爆、删除操作频繁触发 O(n) 拷贝。所以实现时至少要加三层保护系统消息单独保留、超长消息按片段拆分、删除时预留出回复 token 空间。这个方案很多人看不上但它有一个别的架构替代不了的优势零 LLM 调用成本。它不需要额外调用模型去压缩或改写历史所有操作都是纯计算。对于轮次很短、任务目标单一的场景比如“录入一条备注”“查一下天气”固定上下文窗口就是最优解。2.2 ArkTS 源码实现我在模拟项目 X 里用 ArkTS 写了一个最小可用的FixedWindowMemory完整代码如下interface AgentMessage { role: system | user | assistant; content: string; timestamp: number; } interface MemoryConfig { maxTokens: number; reserveTokens: number; } interface AgentMemory { add(msg: AgentMessage): Promisevoid; buildPrompt(): string; } class FixedWindowMemory implements AgentMemory { private messages: AgentMessage[] []; private maxTokens: number 4000; private reserveTokens: number 800; constructor(config: MemoryConfig) { this.maxTokens config.maxTokens; this.reserveTokens config.reserveTokens; } async add(msg: AgentMessage): Promisevoid { this.messages.push(msg); this.trimByToken(); } buildPrompt(): string { let result ; for (const m of this.messages) { result m.role : m.content \n; } return result; } private tokenOf(msg: AgentMessage): number { // 中文场景简化估算大约 2 个字符 1 token // 生产环境建议换成分词器统计否则可能偏差较大 return Math.ceil((msg.content.length 8) / 2); } private totalTokens(): number { let sum 0; for (const m of this.messages) { sum this.tokenOf(m); } return sum; } private trimByToken(): void { const usableMax this.maxTokens - this.reserveTokens; while (this.totalTokens() usableMax this.messages.length 1) { const first this.messages[0]; // 系统消息必须保留所以如果第一条是 system就删它后面的那条 if (first.role system) { if (this.messages.length 1) { break; } this.messages.splice(1, 1); } else { // 数组头部删除是 O(n) 操作轮次多时建议用环形缓冲替代 this.messages.shift(); } } } }这里最关键的是reserveTokens。模型回答也需要 token如果 prompt 把整条请求填满生成阶段会被截断。我的经验是固定窗口方案至少预留 800 到 1000 token具体取决于峰值回复长度。tokenOf里我用字符数除以 2 估算这个对中文场景能凑合但英文和代码场景偏差很大。生产环境还是接一个真正的分词统计函数鸿蒙侧可以用字符级加权或者把嵌入缓存拆成 token 数组避免每次都重新遍历。2.3 固定窗口的真实局限固定窗口最容易被忽略的问题是“截断边界割裂语义”。假设会话里用户说“我家孩子三年级”助手回复了“那可以从简单应用题开始”。如果窗口刚好从“那可以从简单应用题开始”这句助手回复之后截断下一轮用户问“那英语呢”Agent 根本不知道“那”指的是什么。这个问题在固定窗口里无解因为方案本身不感知语义边界。它只知道按 token 删不知道哪些是事实、哪些是结论。如果要在固定窗口基础上挽救语义只能做一块胶水截断时把被删内容的关键字段单独抽出来放进摘要槽。可真要设计摘要槽不如直接切到第三种架构。所以我现在的结论是固定上下文窗口适合做“一次性任务型对话”或者给对话层做兜底。真正的长会话管理需要往下面两种架构走。3. 架构二滑动上下文窗口——窗口边灌边排的双端队列3.1 它和固定窗口到底差在哪很多人把滑动上下文窗口和固定上下文窗口混为一谈觉得不都是删旧消息吗它们有一个根本区别固定窗口按“第几轮”来截断滑动窗口按“token 预算”来维持一个可滚动的窗口。固定窗口的逻辑是我保留最近 10 轮第 11 轮来了第 1 轮就走。滑动窗口的逻辑是我不管几轮只看当前窗口里的总 token 是否超限超了就释放最旧的而且释放之后窗口中间的语义仍然连续。用个不恰当的比喻固定窗口是一列定员的绿皮火车每站必须上人车厢满了就把最前面的人踢下车。滑动窗口更像一条传送带新货物从尾部放上去传送带往前滚旧货物从头掉下去但传送带的载荷始终不变。3.2 源码实现按 token 倒序回填保护系统消息我在模拟项目 X 中的实现如下class SlidingWindowMemory implements AgentMemory { private messages: AgentMessage[] []; private systemMessage: AgentMessage | null null; private maxTokens: number 4000; private overlapTokens: number 320; constructor(systemMsg: AgentMessage | null, maxTokens: number, overlapTokens: number) { this.systemMessage systemMsg; this.maxTokens maxTokens; this.overlapTokens overlapTokens; } async add(msg: AgentMessage): Promisevoid { this.messages.push(msg); this.slide(); } buildPrompt(): string { let result ; for (const m of this.messages) { result m.role : m.content \n; } return result; } private tokenOf(msg: AgentMessage): number { return Math.ceil((msg.content.length 8) / 2); } private slide(): void { const keep: AgentMessage[] []; let used 0; // 减去重叠预留给真正的新对话留出空间 const allowed this.maxTokens - this.overlapTokens; if (this.systemMessage ! null) { const sysToken this.tokenOf(this.systemMessage); used sysToken; keep.push(this.systemMessage); } // 从最新的消息开始往回数尽量把窗口尾部装满 for (let i this.messages.length - 1; i 0; i--) { const item this.messages[i]; const t this.tokenOf(item); if (used t allowed) { break; } keep.push(item); used t; } // 上面的顺序是 [系统消息, 最新, 次新, ...]需要倒序恢复对话顺序 keep.reverse(); // 倒序后系统消息跑到末尾放回开头 if (keep.length 0 keep[keep.length - 1].role system) { const sys keep.pop(); if (sys ! undefined) { keep.unshift(sys); } } this.messages keep; } }这段代码的结构不难但两个点需要特别说明第一overlapTokens不是留给系统消息的是故意让窗口在 token 预算上留有余量。这样模型在下一轮追加回复时不会被顶到硬上限。如果你不留余量系统每次都会在窗口边缘做截断截断频率会成倍增加。第二滑动窗口尽量按“消息边界”来切不切到单条消息内部。这一点很重要否则模型可能看到半个回复、半个工具调用结果输出质量会明显下降。代码里break就是在一条消息放不下时直接放弃更早内容。3.3 滑动窗口的实际表现与意外问题我压测时注意到一个现象滑动窗口早期掉信息的速度比固定窗口慢。固定窗口可能到第 15 轮就开始对早期事实“失忆”滑动窗口能把关键信息保留到第 30 轮左右。原因是它每次按 token 释放不会因为“轮次”这个一刀切标准把所有旧消息整块删掉。但它仍然不是万能。窗口滚到后面中间夹着的大量对话内容还是会消失。更隐蔽的问题是“窗口回滚”当某一次消息特别长窗口可能一次性把前面所有内容都删掉只留下一条长消息。你可以给单条消息设置最大长度超过的部分先摘要或切片否则一条超长工具输出就能冲垮整个窗口。另外滑动窗口没有跨会话记忆。用户第二天回来窗口里是空的Agent 照样不认识你。如果目标场景是“记住上次聊到哪”滑动窗口仍然不够。4. 架构三摘要记忆架构——让 Agent 记住更久以前的事4.1 递归摘要的运作机制摘要记忆的基本思路是不把所有历史塞进 prompt而是每隔一段时间让大模型把已有对话压缩成一段摘要。新的对话来了继续在“摘要 最近消息”的基础上追加。摘要本身也可能太长了再加一层摘要所以叫递归摘要。这个方案最大的优点是把请求体积控制在稳定范围。无论会话进行到第 60 轮还是第 200 轮prompt 都保持在“摘要 最近 N 轮”的规模。代价是每一次压缩都要额外调用一次模型产生额外延迟和成本。做摘要记忆最容易犯的错误是“只压缩不保留原文”。摘要模型本身也可能是“失忆”的它会把很多细节揉成一句概括。比如“用户希望下周三前完成方案初稿并抄送所有项目成员”压缩后可能变成“用户有交付要求”等到模型真正要执行时就拿不出精确信息。我的做法是摘要只承担“长期事实索引”的功能真正决定回复质量的关键事实必须显式抽取出来存成结构化字段。摘要写给模型看结构化字段写给业务逻辑看。4.2 源码实现Summarizer SummaryMemory先定义摘要适配器interface SummaryRequest { history: string; targetTokens: number; } interface SummaryResult { summary: string; sourceTokenCount: number; } interface SummaryAdapter { summarize(req: SummaryRequest): PromiseSummaryResult; }再写SummaryMemoryclass SummaryMemory implements AgentMemory { private summary: string ; private recentMessages: AgentMessage[] []; private adapter: SummaryAdapter; private maxRecentTokens: number 1600; private summaryTokenLimit: number 600; constructor(adapter: SummaryAdapter, maxRecentTokens: number, summaryTokenLimit: number) { this.adapter adapter; this.maxRecentTokens maxRecentTokens; this.summaryTokenLimit summaryTokenLimit; } async add(msg: AgentMessage): Promisevoid { this.recentMessages.push(msg); if (this.tokenOfRecent() this.maxRecentTokens) { await this.compress(); } } buildPrompt(): string { const recent this.recentMessages.map(m m.role : m.content \n).join(); return [长期摘要]\n this.summary \n\n[最近对话]\n recent; } private tokenOfRecent(): number { let sum 0; for (const m of this.recentMessages) { sum Math.ceil((m.content.length 8) / 2); } return sum; } private async compress(): Promisevoid { // 压缩时保留最近两条避免刚说完的话立刻被摘要吞掉 const keepCount 2; if (this.recentMessages.length keepCount) { return; } const sliceEnd this.recentMessages.length - keepCount; const toSummarize this.recentMessages.slice(0, sliceEnd) .map(m m.role : m.content \n) .join(); const historyText this.summary ? this.summary \n toSummarize : toSummarize; const result: SummaryResult await this.adapter.summarize({ history: historyText, targetTokens: this.summaryTokenLimit }); this.summary result.summary; this.recentMessages this.recentMessages.slice(-keepCount); } }压缩时机是maxRecentTokens达到 1600 token。为什么不是每次 add 都判一次因为摘要调用有延迟频繁触发会拖慢对话。我在模拟项目 X 里设了“距上次压缩至少 20 秒”的冷却窗口避免用户连续发消息时反复压缩。4.3 摘要记忆的成本与踩坑点摘要记忆的代价不是写代码而是调参数。maxRecentTokens越小摘要越频繁信息保留越精细但模型调用次数也越多。summaryTokenLimit越大摘要越详细但压缩成本也跟着涨。我用模拟数据测下来1600 / 600 的组合在纪要助手场景里比较均衡。另一个坑是“摘要漂移”。压缩后模型会用自己的理解重组内容如果表述出现偏差后面所有轮次都会基于这个错误摘要继续而且偏差会像滚雪球一样放大。我的补救办法是在摘要提示词里要求“只做提取不做改写”并把每条摘要限制为“事实清单”而不是自然语言段落。这样虽然可读性差一些但幻觉率明显下降。摘要架构也不是万能的。它适合“长期记忆 角色一致性”场景但如果用户进行的是一个强逻辑的长流程任务摘要丢失细节仍然会让流程断裂。这时候通常要配合工具调用参数、数据库记录来辅助不能只靠摘要。5. 三套方案在同一鸿蒙设备上的对比数据5.1 测试口径我在一台中端鸿蒙开发板上跑了固定上下文窗口、滑动窗口、摘要记忆三套方案统一使用同一个云端大模型接口模拟了一个“客户资料收集 多轮确认 任务补充”的长对话一共 120 轮。设备端只做记忆管理和请求拼接模型推理全部在云端。配置如下固定上下文窗口maxTokens 4096reserveTokens 800。滑动上下文窗口maxTokens 4096overlapTokens 320。摘要记忆maxRecentTokens 1600summaryTokenLimit 600。5.2 核心对比数据指标固定上下文窗口滑动上下文窗口摘要记忆15 轮平均单次请求 tokens约 2200约 2050约 105060 轮平均单次请求 tokens约 3900约 3800约 1350120 轮平均单次请求 tokens约 4100已强制截断约 4100约 1450120 轮首 token 延迟1120 ms1240 ms760 ms设备端内存占用约 6.8 MB约 7.2 MB约 18.5 MB早期关键信息召回率35%62%88%每 100 轮明显幻觉次数4.2 次1.8 次1.5 次额外 LLM 调用次数00约 8 次/100 轮5.3 数据背后说明了什么固定上下文窗口在 15 轮时其实很能打token 占用不大延迟也低。但到 120 轮早期信息已经完全丢失关键信息召回率只有三成多基本可以认为“长期失忆”。滑动窗口相当均衡。它硬扛到 60 轮仍然保持 62% 的信息召回率说明按 token 滚动比按轮次硬切效果更好。可它一旦越过窗口边界丢掉的内容同样捡不回来。摘要记忆在 token 占用和延迟上优势非常明显120 轮单次请求只有 1450 token 左右首 token 延迟反而最低。代价是设备端内存占用最高因为要把摘要历史和最近消息都长期驻留而且每 100 轮要多出约 8 次压缩调用这部分额外费用要提前算进成本。这里的数据再一次说明没有“最好的架构”只有“当前场景下最合适的架构”。6. 鸿蒙实战把三种记忆跑进 Agent 调用链路6.1 鸿蒙端 Agent 的整体调用链我把三种记忆统一封装成AgentMemory接口上层组件只依赖接口不关心具体实现。这样在鸿蒙设备上切换架构只需要改一行构造代码。整体调用链是这样的页面收到用户输入 → 写入记忆模块 → 从记忆模块生成 prompt → 请求云端模型接口 → 拿到助手回复 → 再写入记忆模块。整个链路里记忆模块是同步的但模型请求是异步的所以页面里需要处理 loading 状态和错误重试。6.2 ArkTS 页面里的接入示例这里是鸿蒙侧一个最小可运行的接入片段import { http } from kit.NetworkKit; class CloudLLMAdapter implements SummaryAdapter { private endpoint: string https://agent-demo.example.com/v1/chat; private apiKey: string ; async summarize(req: SummaryRequest): PromiseSummaryResult { const prompt 请把下面的对话压缩成事实清单不要改写不超过 req.targetTokens token\n req.history; const content await this.callModel(prompt); return { summary: content, sourceTokenCount: req.history.length }; } private async callModel(userText: string): Promisestring { const httpReq http.createHttp(); const resp await httpReq.request(this.endpoint, { method: http.RequestMethod.POST, header: { Content-Type: application/json }, extraData: JSON.stringify({ messages: [{ role: user, content: userText }], max_tokens: 512 }), expectData: true, readTimeout: 30000 }); if (resp.responseCode ! 200) { throw new Error(model request failed, code resp.responseCode); } const body JSON.parse(resp.result as string); return body.choices[0].message.content; } } Entry Component struct AgentChatPage { State outputText: string 已就绪等待输入; private memory: AgentMemory | null null; private llm: CloudLLMAdapter | null null; aboutToAppear(): void { this.llm new CloudLLMAdapter(); const sysMsg: AgentMessage { role: system, content: 你是纪要助手负责登记需求不要臆造用户没说过的时间。, timestamp: Date.now() }; // 默认切到摘要记忆需要切换时改成 SlidingWindowMemory / FixedWindowMemory 即可 this.memory new SummaryMemory(this.llm, 1600, 600); this.memory.add(sysMsg); } async sendMessage(userText: string): Promisevoid { if (this.memory null || this.llm null) { this.outputText 记忆模块未初始化; return; } const userMsg: AgentMessage { role: user, content: userText, timestamp: Date.now() }; await this.memory.add(userMsg); const prompt this.memory.buildPrompt(); const reply await this.llm.callModel(prompt); const assistantMsg: AgentMessage { role: assistant, content: reply, timestamp: Date.now() }; await this.memory.add(assistantMsg); this.outputText reply; } build() { Column({ space: 12 }) { Text(this.outputText) .fontSize(16) .width(100%) .padding(12) Button(发送示例提问) .onClick(() { this.sendMessage(帮我追加一条下周需要输出风险清单); }) } .width(100%) .padding(16) } }这里把CloudLLMAdapter同时当作摘要器和对话模型来用所以文件名里会出现“Adapter”。6.3 运行期内存与网络异常的兜底在鸿蒙设备上跑这个链路最需要注意的是网络异常和内存抖动。我在模拟项目 X 里遇到过几个典型问题第一Summary 的压缩请求和普通对话请求并发时云端可能返回乱序结果。必须在记忆类内部加一个压缩锁同一时刻只允许一个摘要任务在执行否则摘要写回后会覆盖掉最新消息。第二http请求超时时间不能设太短。第一次摘要压缩可能包含大量历史云端生成时间长我最初设 15 秒经常超时后来改成 30 秒才稳定。第三端侧内存低位时要主动降级。鸿蒙设备如果检测到内存紧张应该把摘要记忆临时降级为滑动窗口否则压缩时大量历史驻留内存反而把设备卡死。7. 选型建议与我的实操心得三种架构都跑完一遍之后我的选型判断是这样的如果做的是“单次任务 短对话”的语音指令、快捷问答直接用固定上下文窗口不用加花活。省一次模型调用就是省一次真金白银而且简单代码不容易出 bug。如果做的是“工具调用 流程式操作”的 Agent比如帮用户订会议、查订单、修改日程优先选滑动上下文窗口。它能在不增加额外调用成本的情况下把信息保留时间延长接近一倍而且实现起来也就几十行代码。如果做的是“跨天记忆 角色一致性”的陪伴型或助理型 Agent那就老老实实上摘要记忆。但要记住摘要不是保险箱压缩后关键事实很可能被抹平。我在实践中会再单独维护一个“用户关键信息表”把姓名、日期、金额、ID 这类必须精确的信息从摘要里抽出来存好不指望模型摘要记住它们。最后说一个我很受用的教训不要一上来就追求最聪明的方案。多轮对话管理的核心不是堆记忆而是让模型在合适的上下文里回答合适的问题。窗口大小、摘要频率、重叠 token 这些参数做成可配置项上线后根据实际对话长度和成本一点一点调比套用任何“最佳实践”都靠谱。如果你也在把 Agent 往鸿蒙设备上搬建议先跑一个 100 轮左右的模拟对话把三套方案的 token 占用和延迟打出来看一眼再决定用哪套。毕竟只有数据和成本才能告诉你答案。