計(jì):從選型到狀態(tài)機(jī)實(shí)戰(zhàn))
1. 先看三個(gè)翻車現(xiàn)場沒有上下文模式會怎樣context-mode這個(gè)詞最近在 LLM 應(yīng)用開發(fā)的圈子里被頻繁提起。我最早看到它的時(shí)候以為只是一個(gè)簡單的開關(guān)——開一下AI 就能記住對話關(guān)一下就是普通的單輪問答。直到我自己在項(xiàng)目里連續(xù)踩了幾個(gè)大坑才意識到這東西遠(yuǎn)比想象中復(fù)雜。它本質(zhì)上是一套對話上下文的管理策略決定了模型在每一輪生成時(shí)到底能看到哪些歷史信息、以什么形態(tài)看到、以及這些信息如何被更新和淘汰。在做智能客服系統(tǒng)時(shí)我曾天真地認(rèn)為只要把多輪對話的歷史消息全部塞進(jìn) prompt 就算是支持上下文了。然后很快碰到了三個(gè)典型的翻車現(xiàn)場幾乎每一個(gè)都讓我懷疑人生。1.1 場景 A多輪對話中的失憶用戶和客服機(jī)器人聊了十幾輪局面已經(jīng)非常清楚——用戶要退一張機(jī)票并且明確說了退票原因是因?yàn)楹桨嘧儎?。結(jié)果因?yàn)槲覀儼焉舷挛拇翱谠O(shè)置得太小前面的關(guān)鍵信息被擠出了窗口模型在第十輪的時(shí)候突然反問請問您是要退哪張機(jī)票那一刻用戶心態(tài)直接崩了。更麻煩的是這種失憶不是偶發(fā)的而是隨著對話輪數(shù)增長必然出現(xiàn)的。如果你采用最簡單的固定窗口截?cái)嗖呗浴槐A糇罱陌溯唽υ挕敲吹诰泡嗛_始第一輪的信息就被丟棄了。而用戶的關(guān)鍵訴求往往恰恰是在前幾輪里說清楚的。1.2 場景 B全局上下文的信息擁堵另一個(gè)項(xiàng)目是文檔問答助手。我把整份產(chǎn)品手冊全部塞進(jìn)了 system prompt再加了用戶的問題一次性發(fā)給模型。結(jié)果同樣很慘。模型確實(shí)知道所有信息但它的注意力被稀釋了回答變得模棱兩可。更要命的是 token 消耗直線上升一次普通問答的調(diào)用成本是之前的五倍而且首字響應(yīng)延遲從 0.8 秒拉到了 3 秒以上。這就是典型的上下文信息過載。你要知道Transformer 的注意力機(jī)制雖然是全局的但模型的表現(xiàn)會隨著 token 數(shù)量的增長而退化尤其是在關(guān)鍵信息埋在一大堆無關(guān)內(nèi)容里的時(shí)候。不是信息越多越好而是關(guān)鍵信息足夠集中才最好。1.3 場景 C多 Agent 協(xié)作中的串臺這個(gè)場景更隱蔽。我在做多智能體協(xié)作系統(tǒng)時(shí)讓幾個(gè) Agent 共享一個(gè)全局上下文容器。本來設(shè)計(jì)的是讀和寫都通過統(tǒng)一接口調(diào)用結(jié)果某個(gè) Agent 在調(diào)試階段誤操作往共享區(qū)域里塞了一條完全無關(guān)的信息——另一個(gè) Agent 在下一次決策時(shí)居然參考了這條信息給出了一個(gè)邏輯荒謬的建議。這個(gè)問題的本質(zhì)是共享上下文缺乏隔離機(jī)制。不同 Agent 的關(guān)注點(diǎn)不同、生命周期不同、信息粒度不同把它們?nèi)M(jìn)同一個(gè)上下文池里等于讓所有人穿同一件衣服尺碼不對是必然的。這三個(gè)場景讓我下定決心認(rèn)認(rèn)真真設(shè)計(jì)一套可落地、可分層的context-mode管理方案。這篇文章就把我這幾個(gè)月的設(shè)計(jì)和踩坑經(jīng)驗(yàn)完整寫出來包含代碼級別的實(shí)現(xiàn)思路、模式切換的狀態(tài)機(jī)設(shè)計(jì)、token 預(yù)算計(jì)算方式以及測出來的那些讓人哭笑不得的邊界情況。適合所有正在做 LLM 應(yīng)用開發(fā)尤其是對話系統(tǒng)、Agent 系統(tǒng)、RAG 問答的工程師參考。2. 四種核心模式按需選型而不是一把梭先明確一個(gè)基本認(rèn)知不存在一種上下文模式能同時(shí)解決所有問題。單輪、滑動窗口、摘要壓縮、結(jié)構(gòu)化記憶這四種模式各有各的適用場景而且它們不是互斥的是一個(gè)系統(tǒng)里可以根據(jù)情況動態(tài)切換的檔位。我最終落地的時(shí)候系統(tǒng)里同時(shí)跑了四種模式靠一個(gè)模式分發(fā)器根據(jù)當(dāng)前對話的狀態(tài)來決定走哪條路。下面逐個(gè)說清楚每種模式的內(nèi)存邏輯和適用邊界。2.1 單輪模式Stateless Mode適合一次性問答場景這是最簡單的一種模式。每一輪請求都是獨(dú)立的不攜帶任何歷史消息。模型只看到當(dāng)前用戶輸入和 system prompt。聽上去很原始但在大量真實(shí)場景里它反而是最優(yōu)解。比如關(guān)鍵詞抽取、實(shí)體識別、意圖分類、單個(gè)事實(shí)問答。這些任務(wù)本身不依賴上下文強(qiáng)行加歷史反而會引入干擾。我見過有人在情感分類任務(wù)里把歷史聊天記錄全塞進(jìn)去結(jié)果模型被歷史里的情緒帶偏把當(dāng)前這一句的正面情緒判成了負(fù)面。代碼上單輪模式只需要在組裝請求時(shí)跳過歷史消息序列def build_messages(strict_mode: bool, current_input: str, history: list[Message]) - list[dict]: if strict_mode: # 單輪模式完全丟棄歷史 return [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: current_input} ] # 其他模式繼續(xù)走 history 拼接邏輯這個(gè)函數(shù)雖然簡單但它是我整個(gè)上下文管理器的入口。所有模式最終都要經(jīng)過這一層來組裝 messages。2.2 滑動窗口模式Sliding Window Mode最常用的保底方案滑動窗口是最直覺、最容易實(shí)現(xiàn)、也是絕大多數(shù)項(xiàng)目默認(rèn)在用的方案。核心邏輯就是一句話只保留最近 N 條歷史消息。這個(gè) N 怎么定不是拍腦袋定的需要根據(jù) token 預(yù)算反推。我習(xí)慣先定一個(gè)窗口的 token 上限然后往里塞消息塞不下就從最老的開始丟。具體計(jì)算方式我在第 4 章詳細(xì)展開?;瑒哟翱诘膬?yōu)點(diǎn)是實(shí)現(xiàn)簡單、延遲低、token 開銷穩(wěn)定。缺點(diǎn)是中期記憶必然丟失。假設(shè)窗口能裝 20 條消息那第 21 條消息進(jìn)來的時(shí)候第 1 條就被擠出去了。如果用戶在第 1 條里說了自己的需求在第 30 條時(shí)又補(bǔ)充了一個(gè)關(guān)鍵細(xì)節(jié)模型大概率已經(jīng)把第 1 條的約束忘了。我當(dāng)前的做法是滑動窗口只作為默認(rèn)檔位一旦檢測到對話涉及關(guān)鍵長期信息就升級到摘要模式或結(jié)構(gòu)化記憶模式。2.3 摘要壓縮模式Summary Mode跑贏窗口上限的唯一辦法當(dāng)對話輪數(shù)實(shí)在太多、無法在 token 預(yù)算內(nèi)完整放進(jìn)去時(shí)摘要壓縮是繞開上限的常規(guī)路徑。核心思路是用一條高度提煉的摘要代表那些已經(jīng)超出窗口范圍的歷史對話讓模型在有限上下文里感知到全局信息。我第一版自己寫摘要邏輯用老辦法——每隔 5 輪調(diào)用一次模型把前面的聊天記錄壓縮成 150 字以內(nèi)的要點(diǎn)存起來作為上下文的前綴。后來發(fā)現(xiàn)效果不穩(wěn)定因?yàn)槟P蜁押芏嗉?xì)節(jié)丟掉導(dǎo)致后面問答的精確度下降。后面我加了結(jié)構(gòu)化摘要的思路效果好了很多。所謂結(jié)構(gòu)化摘要就是按照事先定義好的 schema 輸出而不是讓模型自由發(fā)揮。比如客服場景下摘要必須包含四個(gè)字段SUMMARY_SCHEMA { user_intention: 用戶當(dāng)前的核心訴求, key_facts: [已確認(rèn)的關(guān)鍵事實(shí)如訂單號、時(shí)間、金額], user_emotion: 情緒狀態(tài)平靜/不滿/憤怒, unresolved: 尚未解決的事項(xiàng) }用 JSON 格式約束模型輸出之后摘要模式才變得真正可靠。后續(xù) Agent 從摘要里取信息時(shí)能穩(wěn)定地按照字段解析而不是在自由文本里大海撈針。2.4 結(jié)構(gòu)化記憶模式Memory Mode長期對話的戰(zhàn)略儲備如果說摘要壓縮是壓縮過去的思路那么結(jié)構(gòu)化記憶就是提煉資產(chǎn)的思路。摘要仍然要圍繞已有的對話內(nèi)容轉(zhuǎn)述而記憶模式則是把重要信息顯式存成結(jié)構(gòu)化條目越積越多永不丟失直到被主動更新或刪除。我在系統(tǒng)里給每個(gè)用戶維護(hù)了一個(gè) Memory Bank里面長這樣{ user_id: u_1024, facts: { airline_preference: 國航, seat_preference: 靠窗, member_level: 金卡 }, current_order: { order_no: CA1234, status: refunding, reason: 航班變動 }, negations: [ 不接受改簽到第二天 ] }這個(gè) Memory Bank 不是一次性全量塞進(jìn) prompt。它是按需讀取的——在組裝上下文時(shí)根據(jù)當(dāng)前用戶問題動態(tài)挑選相關(guān)的條目填入。比如用戶下一次提到我要退票系統(tǒng)就把current_order、negations相關(guān)條目讀出來和最近的滑動窗口拼接形成最終上下文。這個(gè)按需讀取很關(guān)鍵避免把所有記憶全部塞入 prompt 導(dǎo)致的 token 膨脹。3. 狀態(tài)機(jī)設(shè)計(jì)四種模式是如何被調(diào)度起來的上面四種模式如果只是各跑各的那還談不上是系統(tǒng)。真正讓它成為一個(gè)完整方案的是中間那層模式調(diào)度邏輯——什么時(shí)候用單輪什么時(shí)候從滑動窗口升級到摘要什么時(shí)候把信息寫入 Memory Bank。我把這一層實(shí)現(xiàn)成了一個(gè)狀態(tài)機(jī)。每個(gè)對話 session 在任意時(shí)刻都處于某一種上下文模式下根據(jù)特定事件觸發(fā)模式切換。3.1 上下文生命周期從創(chuàng)建到回收每個(gè) session 在創(chuàng)建時(shí)先處于STATELESS單輪模式因?yàn)榇藭r(shí)還沒有任何歷史不存在維護(hù)上下文的必要。第一條用戶消息進(jìn)來后系統(tǒng)判斷是否需要開啟多輪模式。如果用戶的問題是一個(gè)獨(dú)立任務(wù)比如翻譯這句話就保持單輪如果是幫我規(guī)劃行程這種天然需要后續(xù)追問的就切到SLIDING_WINDOW。我把生命周期分成了四個(gè)階段階段模式觸發(fā)條件退出條件初始化STATELESSsession 創(chuàng)建檢測到多輪意圖正常對話SLIDING_WINDOW多輪意圖觸發(fā)累計(jì) token 超預(yù)算長對話壓縮SUMMARY窗口 token 超限摘要寫入成功記憶沉淀MEMORY出現(xiàn)可提煉的長期信息記憶條目落庫這個(gè)狀態(tài)機(jī)的切換方向常規(guī)下是單向流動的STALENESS → SLIDING_WINDOW → SUMMARY → MEMORY。但允許回退——比如用戶明確說我們換一個(gè)話題那么舊的 SUMMARY 和 MEMORY 都會清空或標(biāo)記失效session 回到 SLIDING_WINDOW 重新積累。3.2 觸發(fā)條件到底在什么閾值下切換狀態(tài)機(jī)的價(jià)值不在于狀態(tài)定義而在于切換條件的合理性。先說從 SLIDING_WINDOW 切到 SUMMARY 的時(shí)機(jī)。我一開始的做法是當(dāng)最近 5 輪對話的 token 數(shù)總和超過了窗口預(yù)算的 80%就觸發(fā)摘要。這樣做的壞處是頻繁觸發(fā)——用戶稍微多說幾句就壓縮一次壓縮本身要調(diào)一次模型延遲和成本都上去了。后來我把觸發(fā)方式改成了惰性壓縮def need_compress(session) - bool: # 當(dāng)前對話總token含歷史已經(jīng)接近窗口上限的 85% 時(shí)才觸發(fā) return session.estimated_tokens() session.window_budget() * 0.85這樣只有在真正快裝不下的時(shí)候才壓縮盡量減少無謂的模型調(diào)用。再說寫 Memory 的時(shí)機(jī)。不是每輪對話都值得寫入記憶。我使用了一個(gè)基于規(guī)則的特征過濾只有當(dāng)對話中出現(xiàn)明確的偏好類關(guān)鍵詞我喜歡我不喜歡以后都千萬不要時(shí)才會觸發(fā)一次記憶抽取。這個(gè)規(guī)則雖然粗暴但召回率高而且?guī)缀醪粫z漏重要信息。3.3 狀態(tài)機(jī)實(shí)現(xiàn)一個(gè)極簡但完整的狀態(tài)核心實(shí)際代碼里我用了一個(gè)簡單的枚舉加一個(gè) manager 類來管理狀態(tài)。沒有上復(fù)雜的狀態(tài)機(jī)框架因?yàn)槟壳暗哪J綌?shù)量有限手寫更可控。from enum import Enum, auto class ContextMode(Enum): STATELESS auto() SLIDING_WINDOW auto() SUMMARY auto() MEMORY auto() class ContextManager: def __init__(self, user_id: str, window_budget: int 12000): self.mode ContextMode.STATELESS self.history: list[Message] [] self.summary: Summary | None None self.memory MemoryBank(user_id) self.window_budget window_budget def add_turn(self, user_msg: str, assistant_msg: str): self.history.append(Message(roleuser, contentuser_msg)) self.history.append(Message(roleassistant, contentassistant_msg)) # 1. 更新模式 if self.mode ContextMode.STATELESS: if detect_multi_turn_intent(user_msg): self.mode ContextMode.SLIDING_WINDOW elif self.mode ContextMode.SLIDING_WINDOW: if self.estimate_tokens() self.window_budget * 0.85: self.compress_history() # 觸發(fā)摘要壓縮 self.mode ContextMode.SUMMARY elif self.mode ContextMode.SUMMARY: if detect_long_term_facts(user_msg): self.memory.extract_and_store(user_msg) # 2. 管理窗口大小防溢出 self.trim_history(self.window_budget) def build_request_messages(self, current_input: str) - list[dict]: if self.mode ContextMode.STATELESS: base [{role: system, content: SYSTEM_PROMPT}] elif self.mode ContextMode.SUMMARY: base [ {role: system, content: SYSTEM_PROMPT}, {role: system, content: f[對話摘要] {self.summary.text}} ] base self.history[-8:] # 只保留最近部分細(xì)粒度消息 else: base [{role: system, content: SYSTEM_PROMPT}] base self.history[-self.recent_visiable_count():] # 記憶條目按需注入 relevant_memory self.memory.relevant_to(current_input) if relevant_memory: base.insert(1, {role: system, content: f[用戶長期偏好] {relevant_memory}}) base.append({role: user, content: current_input}) return base這段代碼是我實(shí)際項(xiàng)目里跑過的簡化版幾個(gè)設(shè)計(jì)取舍值得說add_turn先更新模式再處理歷史列表順序避免模式切換和消息追加互相踩。trim_history在每次追加后執(zhí)行確保self.history永遠(yuǎn)處在窗口預(yù)算內(nèi)防止內(nèi)存膨脹。build_request_messages中摘要模式和記憶注入都在 system 層完成不用占用 user/assistant 消息位置模型能更穩(wěn)定地把它們當(dāng)作背景設(shè)定而不是待回復(fù)內(nèi)容。摘要模式下我只保留最近 8 條細(xì)粒度消息self.history[-8:]前面的一律交給摘要。這個(gè) 8 是我實(shí)測下來細(xì)粒度信息和摘要信息平衡得最好的一個(gè)值——太少模型會失憶太多又壓縮了摘要的作用。4. Token 預(yù)算計(jì)算與窗口規(guī)劃把賬算明白再動手上下文模式的設(shè)計(jì)如果脫離 token 預(yù)算基本就是空中樓閣。窗口開多大、摘要壓縮頻率多高、歷史消息保留多少條全部由預(yù)算決定。這一章我把整個(gè)計(jì)算過程展開你可以照著算自己項(xiàng)目的參數(shù)。4.1 一個(gè)完整的預(yù)算計(jì)算例子假設(shè)我使用的模型支持 32,768 token 的上下文窗口很多主流模型的標(biāo)準(zhǔn)配置。這個(gè)窗口里需要放下四類東西占用項(xiàng)數(shù)量說明system prompt1,500角色設(shè)定、回答規(guī)則、格式要求工具定義3,000如果涉及 function calling當(dāng)前輸入~500本輪用戶輸入按需估算模型輸出4,096預(yù)留生成空間那么留給歷史上下文的安全預(yù)算就是32768 - 1500 - 3000 - 500 - 4096 23672但這還不是最終可用的全部我習(xí)慣再留 15% 的余量防止單條消息特別長導(dǎo)致的估算偏差23672 * 0.85 ≈ 20121所以滑動窗口的有效預(yù)算大約是20000 token。接下來要記住一個(gè)大數(shù)中文會話里1 個(gè)漢字大約等于 1.5 到 2 個(gè) token。換句話說用戶說一句 40 字的自然語言模型回一句 120 字的回答一輪消息的 token 消耗大約在 350 到 500。按照這個(gè)估算20000 token 的窗口大約能容納40 到 55 輪對話。這比我最初想的能放多少放多少要少得多。這也是為什么滑動窗口在真實(shí)場景里不夠用——正??头υ挸^ 60 輪非常常見窗口必然被撐爆。4.2 怎么算摘要模式節(jié)省了多少摘要模式下我每 10 輪壓縮一次。前面 50 輪對話按原始形態(tài)需要大約 20000 到 25000 token壓縮成摘要后大約只需要 800 到 1200 token。這時(shí)上下文的結(jié)構(gòu)變成摘要(1000) 最近8輪原始消息(約3200) 4200 token整個(gè)上下文被壓縮到了原來的五分之一還不到。這不是免費(fèi)午餐成本在于中間調(diào)了 5 次模型做壓縮。但綜合考慮成本和延遲摘要模式的性價(jià)比依然很高——它換來的是模型對長對話的全局感不再因?yàn)樵缙谛畔⒈粩D出而犯低級錯(cuò)誤。4.3 預(yù)估偏差怎么處理算不準(zhǔn)的問題token 估算永遠(yuǎn)不可能 100% 準(zhǔn)確。不同模型的分詞器對同一段文本的切分結(jié)果不同。好在工程上不需要那么精確。我用了兩套冗余機(jī)制上限硬保護(hù)在組裝 messages 時(shí)如果發(fā)現(xiàn)總 token 數(shù)用tiktoken或transformers的 tokenizer 精確計(jì)算超過窗口上限就觸發(fā)強(qiáng)制裁剪。裁剪順序是先丟最老的歷史消息再丟工具定義仍然超限就強(qiáng)制觸發(fā)摘要壓縮。軟閾值預(yù)警只要估算 token 超過預(yù)算的 85%就提前做一次壓縮或降級避免到了下一輪直接爆掉。def trim_history(self, budget: int): while self.estimate_actual_tokens(self.history) budget * 0.9: if len(self.history) 2: break # 丟棄最老的一條消息 self.history.pop(0)這段代碼是我在實(shí)際線上服務(wù)里直接運(yùn)行的邏輯。它不完美——丟棄最老消息策略在信息價(jià)值上并不是最優(yōu)的但勝在簡單可控。更聰明的方案是按信息價(jià)值丟棄比如優(yōu)先保留包含用戶明確約束的消息但那個(gè)需要額外的語義判斷成本高我目前沒有在核心路徑上啟用。5. 實(shí)測中的翻車點(diǎn)與解決鏈路方案設(shè)計(jì)得再漂亮到了實(shí)測環(huán)節(jié)照樣會翻車。我在壓測和線上灰度階段碰到的這幾個(gè)問題每一個(gè)都值得單獨(dú)拿出來說。5.1 模式切換瞬間的信息斷裂第一次做模式切換時(shí)我遇到了一個(gè)非常尷尬的 bug在第 52 輪滑動窗口模式切換到摘要模式摘要生成完成之后模型突然忘了用戶的姓名。排查鏈路是這樣的先確認(rèn)摘要內(nèi)容——摘要里確實(shí)沒有包含用戶姓名。模型在壓縮時(shí)把它當(dāng)作不重要的信息丟掉了。再看窗口裁剪——切換時(shí)trim_history把老消息全裁了姓名只存在于被裁掉的那部分里。最終定位這是模式切換本身的問題摘要生成時(shí)丟了一個(gè)對當(dāng)前任務(wù)并不重要、但對后續(xù)對話有長期價(jià)值的字段。解決方案也簡單在觸發(fā)壓縮之前先檢測需要保留的關(guān)鍵實(shí)體清單把清單里的信息強(qiáng)制追加到摘要中CRITICAL_ENTITIES [user_name, order_no, contact_phone, invoice_required] def compress_history(self): critical_info self.extract_critical_entities(self.history) summary_text generate_summary(self.history) self.summary Summary(textsummary_text 關(guān)鍵信息: str(critical_info))這樣即使在摘要的正文把姓名丟掉后面的關(guān)鍵信息后綴也會兜底。5.2 上下文重復(fù)注入的回音壁第二個(gè)坑更隱蔽。在摘要模式和 Memory 模式同時(shí)開啟后我發(fā)現(xiàn)模型開始復(fù)讀某些信息。比如用戶明明只在第 3 輪說過一次我喜歡靠窗座位到了第 30 輪模型每次回答都會提到靠窗座位哪怕當(dāng)前話題根本不涉及座位。查到最后發(fā)現(xiàn)同一個(gè)事實(shí)被同時(shí)存在了摘要里和 Memory Bank 里。摘要模式把靠窗寫進(jìn)了摘要文本Memory 模式又把靠窗存成了一條結(jié)構(gòu)化記憶。組裝上下文時(shí)兩處信息同時(shí)生效模型接受到的信息被重復(fù)加權(quán)就會傾向于過度強(qiáng)調(diào)它。解決方式有兩個(gè)層面。第一是去重在寫入 Memory 之前先查重如果摘要中已經(jīng)存在該事實(shí)就不再重復(fù)寫入 Memory或者反過來一旦寫入 Memory就從摘要中移除該事實(shí)的顯式描述。第二是給上下文內(nèi)容加權(quán)重在 prompt 中明確標(biāo)注以下摘要中包含的信息已過時(shí)以 Memory 條目為準(zhǔn)。第二個(gè)方案雖然有點(diǎn)粗暴但在工程實(shí)踐中反而更有效。因?yàn)檎娜ブ厥且患茈y精確完成的事情——摘要文本是自由文本你要判斷它是否包含某條記憶信息又得做一次語義匹配成本太高。5.3 多會話并發(fā)的上下文污染這個(gè)問題出現(xiàn)在我把 ContextManager 接入 Web 服務(wù)之后。最初我把所有用戶的 ContextManager 實(shí)例存在一個(gè)全局字典里key 是 user_id。看起來沒問題直到某次線上事故兩個(gè) user_id 恰好被某個(gè)上游服務(wù)寫錯(cuò)了導(dǎo)致 B 用戶看到了 A 用戶的摘要直接串號。排查鏈路檢查代碼發(fā)現(xiàn) ContextManager 創(chuàng)建時(shí)把 MemoryBank 和 user_id 綁定了但全局字典的 key 用的是另一個(gè) request 級別的 session_id。當(dāng) session_id 不重復(fù)時(shí)沒問題一旦 session_id 在網(wǎng)關(guān)層被復(fù)用連接池場景下常見就會串。這個(gè)問題的根本原因是上下文容器和會話標(biāo)識的一致性沒有在同一層保證。修復(fù)方案是在 ContextManager 內(nèi)部強(qiáng)制校驗(yàn)class ContextManager: def __init__(self, session_id: str, user_id: str): if not session_id or not user_id: raise ValueError(session_id and user_id must not be empty) self.key f{user_id}:{session_id} self.memory MemoryBank(user_id) # 關(guān)鍵Memory 永遠(yuǎn)綁 user不綁 session另外還加了一層字典訪問的防護(hù)在取出實(shí)例時(shí)檢查內(nèi)部綁定的 user_id 是否與當(dāng)前請求一致不一致就重建實(shí)例并記錄告警日志。自從上了這個(gè)校驗(yàn)串號問題再沒出現(xiàn)過。5.4 滑動窗口在流式輸出場景下的著裝后置最后一個(gè)是很容易被人忽略的細(xì)節(jié)。我們的客服系統(tǒng)采用了流式輸出——模型一邊生成用戶一邊看到內(nèi)容。這意味著模型輸出在最后一個(gè) token 落地之前是未完成的。我最初的設(shè)計(jì)是在add_turn中立即把 assistant 消息追加到 history。但流式輸出時(shí)assistant 消息是逐步生成的如果在生成中途就追加會導(dǎo)致 history 里出現(xiàn)被截?cái)嗟陌刖湓?。下一輪請求時(shí)這些殘句會被模型當(dāng)成正常內(nèi)容產(chǎn)生很怪異的影響。修復(fù)方式是引入一個(gè) pending 緩沖class ContextManager: def start_streaming(self): self.pending_assistant def append_stream_chunk(self, chunk: str): self.pending_assistant chunk def finish_streaming(self): if self.pending_assistant: self.history.append(Message(roleassistant, contentself.pending_assistant)) self.pending_assistant self.trim_history(self.window_budget)只有完整接收完整個(gè)生成結(jié)果后才把它寫入 history。這個(gè)細(xì)節(jié)看起來簡單但它直接影響下一輪問答質(zhì)量——畢竟拿別人說了一半的話當(dāng)參考誰都會理解錯(cuò)。6. 進(jìn)階調(diào)優(yōu)模式嗅探、熱冷分層與可觀測性基礎(chǔ)版本跑通之后我還有三個(gè)方向在做持續(xù)優(yōu)化這里一并分享一下思路和已落地的優(yōu)化點(diǎn)。6.1 按問題類型動態(tài)選模式模式嗅探前面提到的狀態(tài)機(jī)是被動式切換——先積累到閾值再切。現(xiàn)在我在嘗試更主動的方式在第一輪請求進(jìn)入時(shí)就通過一個(gè)快速分類器預(yù)測該對話的會話深度預(yù)期直接決定初始模式。比如用戶說幫我把這段文字翻譯成英文這是一個(gè)典型的單次任務(wù)直接把會話置為STATELESS省去了切來切去的開銷。而幫我比較一下三款手機(jī)哪個(gè)適合打游戲天然攜帶多輪屬性直接就置為SLIDING_WINDOW加摘要預(yù)備。實(shí)現(xiàn)上我用了一個(gè)極輕量的意圖分類層本質(zhì)上是一個(gè)幾千條規(guī)則加一個(gè)小的 embedding 分類器判別速度在 10ms 以內(nèi)。規(guī)則部分的幾個(gè)典型特征包含為什么怎么樣具體說說 → 多輪意圖包含翻譯一下總結(jié)一下提取關(guān)鍵詞 → 單輪任務(wù)包含記住以后都我不喜歡 → 啟用 Memory 模式包含 對比比較哪個(gè)更好 → 啟用長窗口模式這層嗅探不需要很精確。它有 80% 的準(zhǔn)確率就能幫系統(tǒng)省下大量無謂的模式切換成本。剩下 20% 的不準(zhǔn)會由狀態(tài)機(jī)在運(yùn)行中自動糾正。6.2 上下文熱度分層熱、溫、冷三層另一個(gè)顯著提升效果的優(yōu)化是上下文熱度分層。不再把歷史消息簡單地留 N 條而是分為三層熱層最近 5 輪完整保留精細(xì)到字。溫層更早的歷史抽取關(guān)鍵信息以要點(diǎn)列表保留。冷層已經(jīng)存入 Memory Bank 的長期事實(shí)按需讀取。熱層的消息直接拼接到 request溫層的要點(diǎn)放在摘要前綴里冷層的記憶條目按當(dāng)前問題相關(guān)性動態(tài)注入。這樣一個(gè)三層結(jié)構(gòu)能同時(shí)照顧到短期對話的連貫性、中期信息的可回溯性、長期知識的持久性。我測試下來這個(gè)分層結(jié)構(gòu)比單一滑動窗口 摘要的效果穩(wěn)定得多。而且它天然配合狀態(tài)機(jī)——熱層由滑動窗口管理溫層由摘要模式管理冷層由 Memory 模式管理每一層各司其職。6.3 可觀測性不看數(shù)據(jù)就調(diào)不好上下文做上下文管理最怕的就是感覺不對但說不清哪里不對。我建議從一開始就把可觀測性納入設(shè)計(jì)至少要記錄以下指標(biāo)指標(biāo)獲取方式用途每輪 token 消耗組裝 request 時(shí)精確統(tǒng)計(jì)定位預(yù)算超支點(diǎn)模式切換次數(shù)狀態(tài)機(jī)事件埋點(diǎn)發(fā)現(xiàn)抖動切換摘要命中率人工抽測評估壓縮質(zhì)量Memory 讀取命中率Memory 查詢?nèi)罩九袛嘤洃洍l目是否對回答有實(shí)際幫助上下文裁剪次數(shù)trim_history調(diào)用日志判斷窗口是否長期偏小這些指標(biāo)上線之后你會發(fā)現(xiàn)很多玄學(xué)問題其實(shí)都是數(shù)據(jù)問題。比如之前我總覺得某個(gè)用戶的對話質(zhì)量時(shí)好時(shí)壞查日志才發(fā)現(xiàn)——他每次對話都會在窗口邊緣被裁剪說明窗口對該類用戶來說偏小這人應(yīng)該走摘要模式而不是滑動窗口。7. 一些實(shí)用的兜底建議在你決定照抄上面的方案之前有幾條我這個(gè)過來人想多說兩句的坑。第一不要為了支持上下文強(qiáng)行上多輪模式。很多需求其實(shí)是單輪任務(wù)硬上多輪反而會把歷史里的噪聲帶進(jìn)來。我的經(jīng)驗(yàn)是能單輪解決的問題就不要讓狀態(tài)機(jī)增加復(fù)雜度。第二摘要壓縮不能做得太頻繁。壓縮本身要調(diào)一次大模型是有成本的。我見過有人每三輪就壓縮一次結(jié)果就是 ContextManager 變成調(diào)模型狂魔成本翻了 20 倍效果卻沒有明顯提升。壓縮的觸發(fā)閾值寧可調(diào)低一點(diǎn)、保守一點(diǎn)讓摘要晚一點(diǎn)出現(xiàn)、大一點(diǎn)概括。第三Memory Bank 里的記憶條目一定要帶時(shí)間戳和置信度。我一開始沒帶結(jié)果用戶后來明確說我不喜歡靠窗了以后訂中間的位置系統(tǒng)不知道該信哪條。加上時(shí)間戳后邏輯非常清楚后來寫入的覆蓋先前的且我們可以設(shè)置權(quán)威覆蓋標(biāo)志——某些字段比如會員等級以業(yè)務(wù)系統(tǒng)數(shù)據(jù)為準(zhǔn)模型抽取的記憶不能覆蓋。第四嚴(yán)格處理好流式輸出和異步寫入的并發(fā)問題。如果不加鎖或者緩沖區(qū)流式產(chǎn)生半截消息進(jìn)入 history 的情況幾乎必然出現(xiàn)。這屬于那種不炸不知道一炸就摸不著頭腦的隱形 bug。第五給 ContextManager 加上 trace_id 貫穿日志。我見過太多人調(diào)試上下文問題時(shí)因?yàn)檎也坏饺罩炬溌范鵁o從下手。每組裝一次 request就輸出一條 trace_id 加消息骨架結(jié)構(gòu)的日志后面排查問題能省一半時(shí)間。這幾個(gè)建議談不上優(yōu)雅但拿它們?nèi)ザ档谆灸鼙WC你的上下文管理系統(tǒng)不往失控的方向跑。用戶對對話質(zhì)量的感知往往非常敏感——一旦模型說出一句我不記得你剛才說了什么提升十個(gè)點(diǎn)準(zhǔn)確率換來的信任也會當(dāng)場歸零所以寧可謹(jǐn)慎不要冒進(jìn)。