戰(zhàn):從CLI工具到LLM會(huì)話的上下文管理設(shè)計(jì))
我記得很清楚第一次意識(shí)到上下文重要性的場(chǎng)景是在寫一個(gè)小型終端助手的時(shí)候。這個(gè)工具需要用戶連續(xù)完成幾步操作先選擇數(shù)據(jù)源再指定處理方式最后輸出報(bào)告。但每次命令跑完它就什么都不記得了用戶下一步如果漏掉某個(gè)參數(shù)只能重新敲一遍。那一刻我意識(shí)到?jīng)]有上下文的工具就像一個(gè)每次都重新認(rèn)識(shí)你的柜臺(tái)服務(wù)員你不得不一遍遍重復(fù)自己的需求。context-mode就是為解決這類問(wèn)題設(shè)計(jì)的。簡(jiǎn)單來(lái)說(shuō)它是一種讓軟件在多次操作或多次會(huì)話之間自動(dòng)保存、讀取并利用歷史上下文信息的運(yùn)行模式。它解決的問(wèn)題很具體減少重復(fù)輸入、保持操作連續(xù)性、讓程序能根據(jù)之前發(fā)生了什么做出更合理的判斷。如果你是做CLI工具、自動(dòng)化腳本、編輯器插件或者自己在折騰AI助手相關(guān)的東西這篇文章值得看完——我會(huì)從設(shè)計(jì)思路講到代碼實(shí)現(xiàn)再分享實(shí)測(cè)中踩過(guò)的三個(gè)坑。1. context-mode是什么先從一次尷尬的失憶說(shuō)起1.1 沒(méi)有上下文的世界你每天都在重復(fù)填表先想想你平時(shí)用的命令行工具尤其是那些要傳一堆參數(shù)的工具。比如一個(gè)數(shù)據(jù)遷移腳本你可能要寫成這樣python migrate.py --source /data/old --target /data/new --format csv --encoding utf-8如果跑到一半掛了或者你想換個(gè)格式再跑一次你大概率會(huì)把這條命令翻出來(lái)改一改或者按一下上箭頭重新編輯。這在單次操作里問(wèn)題不大但如果是連續(xù)幾十條命令呢比如你要依次做預(yù)處理、清洗、校驗(yàn)、導(dǎo)入、導(dǎo)出每一步都要重復(fù)指定路徑、編碼、分隔符時(shí)間一長(zhǎng)忘參數(shù)幾乎是必然的。沒(méi)有上下文的世界本質(zhì)上是把本該由程序記住的信息全壓在了用戶身上。這就像你每天去同一個(gè)快遞點(diǎn)取件每次都報(bào)一遍姓名、電話、地址??爝f點(diǎn)的工作人員不是不想記住你而是他的系統(tǒng)沒(méi)這個(gè)能力。context-mode要做的就是給程序加上這個(gè)記憶能力。1.2 context-mode的定位讓工具記住剛才發(fā)生了什么那么context-mode具體指什么我傾向于給它一個(gè)精確的定義一種運(yùn)行模式在這種模式下程序會(huì)將用戶當(dāng)前的操作、輸入?yún)?shù)、選中項(xiàng)、臨時(shí)狀態(tài)等信息記錄下來(lái)并在后續(xù)操作中自動(dòng)讀取和套用從而形成連續(xù)的操作體驗(yàn)。它和普通的日志、緩存有本質(zhì)區(qū)別。日志是寫給開(kāi)發(fā)者調(diào)試用的緩存是為了加速重復(fù)讀取而context-mode關(guān)心的是語(yǔ)義上的連續(xù)性。舉個(gè)例子你在一個(gè)文件管理器里選中了一個(gè)文件然后準(zhǔn)備執(zhí)行壓縮操作——壓縮工具預(yù)先把當(dāng)前選中了哪個(gè)文件這個(gè)信息記下來(lái)這就是上下文。再比如AI聊天助手它能記得你五分鐘前問(wèn)過(guò)什么這個(gè)記得就是上下文模式在起作用。我見(jiàn)過(guò)的context-mode實(shí)現(xiàn)有很多種形態(tài)基于會(huì)話服務(wù)器端為每個(gè)連接維護(hù)獨(dú)立上下文比如SSH會(huì)話。基于項(xiàng)目目錄進(jìn)入某個(gè)項(xiàng)目目錄后自動(dòng)加載該項(xiàng)目相關(guān)的偏好設(shè)置。基于全局用戶級(jí)別的默認(rèn)配置任何地方都能訪問(wèn)?;跁r(shí)間窗口只記錄最近N分鐘或N條操作形成滾動(dòng)上下文。這里面的核心難點(diǎn)不是記錄數(shù)據(jù)而是決定哪些數(shù)據(jù)值得記錄、什么時(shí)候該遺忘、上下文沖突時(shí)怎么處理。1.3 什么場(chǎng)景真正需要它三個(gè)最典型的應(yīng)用場(chǎng)景我在實(shí)際項(xiàng)目里總結(jié)出三類最需要context-mode的場(chǎng)景你可以對(duì)照自己的需求來(lái)判斷。第一類是多步驟CLI工具。我之前寫過(guò)一個(gè)批量圖片處理工具包含導(dǎo)入、去重、壓縮、重命名、導(dǎo)出五個(gè)步驟。如果每一步都要求用戶重新輸入輸入目錄、輸出目錄、質(zhì)量參數(shù)用起來(lái)會(huì)非常痛苦。加上context-mode之后用戶只需要在第一次運(yùn)行時(shí)提供完整參數(shù)后續(xù)跑任何單步操作都會(huì)自動(dòng)帶上省事很多。第二類是IDE和編輯器插件。比如你正在編輯一個(gè)Python文件然后按下快捷鍵跳到一個(gè)工具函數(shù)定義處改完之后返回原來(lái)的位置——編輯器記住了你剛才在哪里。這種上下文記錄已經(jīng)非常普遍了我們?cè)谧霾寮_(kāi)發(fā)時(shí)也常常需要記錄光標(biāo)位置、當(dāng)前文件路徑、最近打開(kāi)的文件列表這就是一種典型的window-level context。第三類是AI助手的連續(xù)對(duì)話。當(dāng)前所有主流AI對(duì)話產(chǎn)品都在做上下文管理因?yàn)榘颜螝v史全部塞給模型既浪費(fèi)tokens又會(huì)超出模型的上下文窗口。實(shí)際工程中我們往往需要保留最近幾輪對(duì)話而對(duì)更久之前的內(nèi)容做摘要或裁剪。這個(gè)保留多少、裁剪多少的策略本質(zhì)上是context-mode的策略選擇。除此之外運(yùn)維自動(dòng)化、構(gòu)建系統(tǒng)、數(shù)據(jù)管道調(diào)度等領(lǐng)域也大量依賴上下文。判斷一個(gè)系統(tǒng)適不適合引入context-mode我有個(gè)簡(jiǎn)單的經(jīng)驗(yàn)如果你發(fā)現(xiàn)使用者在反復(fù)輸入或反復(fù)選擇相同的信息并且這些信息跟上一次操作強(qiáng)相關(guān)那這個(gè)系統(tǒng)就值得改造。2. 設(shè)計(jì)一個(gè)合理的上下文模型數(shù)據(jù)結(jié)構(gòu)和生命周期2.1 上下文粒度全局、會(huì)話、項(xiàng)目三層的取舍在動(dòng)手寫代碼之前必須先想清楚上下文數(shù)據(jù)要分成幾層。如果不分層所有數(shù)據(jù)混在一起很快就會(huì)出現(xiàn)張三的項(xiàng)目配置被李四帶走了這種問(wèn)題。我在context-mode項(xiàng)目里設(shè)計(jì)了三個(gè)層級(jí)非常夠用層級(jí)作用范圍典型內(nèi)容生命周期global當(dāng)前用戶所有操作用戶名、默認(rèn)編輯器、常用路徑長(zhǎng)期保留session一次工具進(jìn)程的運(yùn)行周期當(dāng)前打開(kāi)的臨時(shí)文件、臨時(shí)的token會(huì)話結(jié)束即刪除project當(dāng)前項(xiàng)目/工作目錄項(xiàng)目根路徑、依賴目錄、構(gòu)建命令項(xiàng)目存在期間這套設(shè)計(jì)解決了兩個(gè)關(guān)鍵問(wèn)題。一個(gè)是隔離性不同項(xiàng)目的上下文不會(huì)互相污染即使是同一個(gè)工具在項(xiàng)目A和項(xiàng)目B下會(huì)自動(dòng)加載各自的設(shè)置。另一個(gè)是時(shí)效性session里的狀態(tài)本來(lái)就該用完即焚比如某次登錄產(chǎn)生的臨時(shí)憑證如果放到了global里那就是給自己埋雷。有人可能會(huì)問(wèn)為什么不做成數(shù)據(jù)庫(kù)表用外鍵關(guān)聯(lián)我的答案是對(duì)于大部分工具型項(xiàng)目三層結(jié)構(gòu)用嵌套字典就夠了。數(shù)據(jù)庫(kù)適合海量數(shù)據(jù)檢索而context數(shù)據(jù)通常單用戶、低并發(fā)、量也不大用樹(shù)狀結(jié)構(gòu)反而更直觀調(diào)試時(shí)候一眼就能看到當(dāng)前上下文里有什么。2.2 持久化選型為什么用JSON加內(nèi)存兩級(jí)緩存確定層級(jí)結(jié)構(gòu)后接下來(lái)要考慮數(shù)據(jù)存哪。我先說(shuō)結(jié)論我采用了內(nèi)存字典做熱存儲(chǔ) JSON文件做冷持久化的組合而不是直接上SQLite。內(nèi)存字典的好處是快讀寫都在納秒級(jí)適合高頻訪問(wèn)。JSON文件的好處是方便調(diào)試——你可以直接用vim或cat打開(kāi)來(lái)看如果用了SQLite還得裝個(gè)客戶端才能查。對(duì)于context-mode這種偏工具性質(zhì)的組件易調(diào)試比查詢性能重要得多。具體做法是啟動(dòng)時(shí)把JSON文件加載進(jìn)內(nèi)存形成一個(gè)三層嵌套字典。每次set操作先更新內(nèi)存再?zèng)Q定是否寫盤。寫盤不能每次set都做否則高頻操作時(shí)IO會(huì)成為瓶頸。我的做法是做一個(gè)臟標(biāo)記在程序退出、或者每隔一段時(shí)間批量落盤。下面是我最初的存儲(chǔ)結(jié)構(gòu)設(shè)計(jì)示意{ global: { default_editor: vim }, session: { login_time: 1700000000 }, project: { /home/user/myapp: { build_command: make, last_output: Build OK } } }這里把project的鍵設(shè)計(jì)成了項(xiàng)目絕對(duì)路徑天然實(shí)現(xiàn)了項(xiàng)目級(jí)隔離。后來(lái)又發(fā)現(xiàn)一個(gè)細(xì)節(jié)值得注意路徑里可能含有特殊字符所以我在加載時(shí)對(duì)路徑做了規(guī)范化處理去掉末尾斜杠、解析符號(hào)鏈接避免同一個(gè)項(xiàng)目因?yàn)槁窂綄懛ú煌霈F(xiàn)兩個(gè)上下文副本。2.3 上下文有效期過(guò)期策略與自動(dòng)清理機(jī)制上下文不是無(wú)限堆積的否則用戶會(huì)發(fā)現(xiàn)三個(gè)月前的一次操作記錄還影響了今天的執(zhí)行結(jié)果這反而變成了一種困擾。我設(shè)計(jì)的過(guò)期策略是給每條上下文數(shù)據(jù)附加元信息{ value: data, created_at: 1700000000, updated_at: 1700000000, ttl: 3600 }created_at和updated_at分別記錄創(chuàng)建時(shí)間和最后更新時(shí)間。ttl是存活時(shí)間秒例如3600表示1小時(shí)后過(guò)期。讀取時(shí)如果now - updated_at ttl就認(rèn)為這條數(shù)據(jù)已過(guò)期不返回給調(diào)用方。這里我要強(qiáng)調(diào)一個(gè)容易犯的錯(cuò)很多人把TTL只寫在層級(jí)上比如整個(gè)session層1小時(shí)過(guò)期這會(huì)引發(fā)連鎖問(wèn)題——一個(gè)長(zhǎng)任務(wù)可能只需要某些字段長(zhǎng)期有效但整個(gè)層級(jí)過(guò)期會(huì)把它一并殺掉。正確的做法是字段級(jí)TTL不同字段可以單獨(dú)指定有效期。比如當(dāng)前登錄用戶可以永久上次選中的文件只需要5分鐘。清理機(jī)制我用的是惰性刪除 定期掃描雙保險(xiǎn)。惰性刪除是在get時(shí)發(fā)現(xiàn)過(guò)期就順便刪掉定期掃描是每隔10分鐘把過(guò)期數(shù)據(jù)批量清掉避免臟數(shù)據(jù)堆積占內(nèi)存。這樣既保證實(shí)時(shí)性又不至于每次操作都觸發(fā)大量刪除。3. 動(dòng)手實(shí)現(xiàn)一個(gè)可用的context-mode模塊Python版3.1 核心類設(shè)計(jì)ContextManager的接口定義設(shè)計(jì)完成后實(shí)現(xiàn)階段需要用代碼把想法固定下來(lái)。這一節(jié)我用Python來(lái)寫一個(gè)可直接運(yùn)行的context-mode模塊語(yǔ)言無(wú)關(guān)性很好你換成Go、Node.js也是一樣的思路。先定義核心類的接口。我習(xí)慣先寫注釋再寫實(shí)現(xiàn)因?yàn)檫@樣能逼自己把接口想清楚import json import os import threading import time from pathlib import Path class ContextManager: Manage context data at global/session/project levels with TTL support. def __init__(self, storage_path): self.storage_path Path(storage_path) self._lock threading.Lock() self._context {global: {}, session: {}, project: {}} self._load() def _load(self): if self.storage_path.exists(): with open(self.storage_path, r, encodingutf-8) as f: loaded json.load(f) self._context.update(loaded) def set(self, level, key, value, ttlNone): Set a value in the given level. level: global | session | project key: string key value: any JSON-serializable value ttl: optional time-to-live in seconds with self._lock: meta { value: value, created_at: time.time(), updated_at: time.time(), ttl: ttl, } self._context.setdefault(level, {})[key] meta def get(self, level, key): Get a value. Return None if key doesnt exist or is expired. with self._lock: meta self._context.get(level, {}).get(key) if meta is None: return None if meta[ttl] is not None: if time.time() - meta[updated_at] meta[ttl]: del self._context[level][key] return None return meta[value] def save(self): Persist current context to disk. with self._lock: tmp_file self.storage_path.with_suffix(.tmp) with open(tmp_file, w, encodingutf-8) as f: json.dump(self._context, f, ensure_asciiFalse, indent2) tmp_file.replace(self.storage_path)這個(gè)類雖然只有幾十行但已經(jīng)包含了最核心的能力設(shè)置、讀取、過(guò)期判斷、落盤。其中save用了一個(gè)臨時(shí)文件加原子替換的技巧避免直接寫原文件時(shí)如果程序崩潰導(dǎo)致文件損壞。這是一個(gè)我踩過(guò)坑后的經(jīng)驗(yàn)后面第4節(jié)還會(huì)詳細(xì)說(shuō)。3.2 一個(gè)簡(jiǎn)單的CLI集成示例從保存到讀取光有類還不夠我把它集成進(jìn)一個(gè)真實(shí)的CLI場(chǎng)景來(lái)演示。假設(shè)我們要做一個(gè)圖片壓縮工具壓縮之前需要用戶指定輸入目錄和質(zhì)量參數(shù)。我用argparse來(lái)解析命令命令行沒(méi)有提供時(shí)就從上下文讀取import argparse from pathlib import Path ctx ContextManager(~/.myapp/context.json) def require_dir(arg_valueNone): Return provided arg value, or read from context if available. if arg_value: ctx.set(project, input_dir, arg_value) return arg_value cached ctx.get(project, input_dir) if cached: return cached raise SystemExit(請(qǐng)輸入輸入目錄或者先設(shè)置過(guò)context-mode) if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--input-dir, defaultNone) parser.add_argument(--quality, typeint, defaultNone) args parser.parse_args() input_dir require_dir(args.input_dir) quality args.quality if quality is None: quality ctx.get(project, quality) or 85 ctx.set(project, quality, quality) ctx.save() print(f開(kāi)始?jí)嚎s輸入目錄{input_dir}質(zhì)量{quality})注意require_dir的設(shè)計(jì)如果用戶傳了新參數(shù)就會(huì)更新上下文如果沒(méi)傳就嘗試從上下文取值。這就是context-mode的核心體驗(yàn)——今天少敲幾個(gè)參數(shù)明天少敲更多越用越順手。3.3 代碼走讀關(guān)鍵的邊界條件處理這幾十行代碼里有幾個(gè)邊界條件是實(shí)際跑起來(lái)之后才會(huì)發(fā)現(xiàn)的。第一個(gè)是存儲(chǔ)路徑的擴(kuò)展符處理。Path(~/.myapp/context.json)在Python中不會(huì)自動(dòng)展開(kāi)波浪號(hào)必須調(diào)用Path.expanduser()。我在最初版本里漏了這一步導(dǎo)致一直找不到文件后來(lái)排查了很久。正確寫法是self.storage_path Path(storage_path).expanduser()第二個(gè)是過(guò)期數(shù)據(jù)在get之后才刪除。我的實(shí)現(xiàn)里如果get時(shí)發(fā)現(xiàn)TTL過(guò)期會(huì)先把數(shù)據(jù)刪掉再返回None。但如果在刪除之前另一個(gè)線程正好也在讀同一個(gè)key就會(huì)出現(xiàn)競(jìng)態(tài)。我在get外層加了線程鎖這就是_lock的作用。不過(guò)對(duì)單文件來(lái)說(shuō)這個(gè)鎖只在進(jìn)程內(nèi)有效如果多個(gè)進(jìn)程同時(shí)讀寫同一個(gè)JSON文件還需要加文件鎖這個(gè)坑我第4節(jié)會(huì)專門講。第三個(gè)是set的值必須可JSON序列化。如果你存了個(gè)自定義對(duì)象json.dump時(shí)會(huì)直接拋TypeError。更隱蔽的是如果存的是tuplejson會(huì)把它變成list下次讀出來(lái)類型就變了。如果想保留類型信息必須在set時(shí)做轉(zhuǎn)換檢查。我在實(shí)際項(xiàng)目中加了這樣一個(gè)斷言def _ensure_json_serializable(self, value): try: json.dumps(value) except TypeError: raise TypeError(f值 {value} 無(wú)法序列化為JSON)這些邊界條件看起來(lái)不復(fù)雜但往往就是能用和好用之間的差距。4. 實(shí)測(cè)中的坑與排查過(guò)程數(shù)據(jù)覆蓋、過(guò)期混亂和隱私泄漏4.1 坑一并發(fā)寫入導(dǎo)致上下文互相覆蓋代碼看起來(lái)沒(méi)問(wèn)題的部分一上實(shí)測(cè)就暴露了第一個(gè)大坑。我的工具支持并行處理多個(gè)任務(wù)于是會(huì)同時(shí)跑多個(gè)進(jìn)程每個(gè)進(jìn)程都會(huì)調(diào)用ctx.save()結(jié)果經(jīng)常出現(xiàn)進(jìn)程A把上下文寫到一半進(jìn)程B也寫過(guò)來(lái)了最終文件里數(shù)據(jù)錯(cuò)亂甚至JSON解析直接失敗。排查過(guò)程是這樣的我先檢查了代碼里的線程鎖_lock發(fā)現(xiàn)它只能鎖住當(dāng)前進(jìn)程內(nèi)的線程進(jìn)程之間完全不管用。兩個(gè)進(jìn)程同時(shí)執(zhí)行tmp_file.replace(self.storage_path)時(shí)雖然臨時(shí)文件是各自獨(dú)立的但替換操作本身不是原子的關(guān)聯(lián)操作集合——A替換成功B替換成功后面的覆蓋了前面的無(wú)法合并。解決辦法是讓進(jìn)程間也能串行化。我引入了一個(gè)簡(jiǎn)單的文件鎖基于操作系統(tǒng)層面的fcntlimport fcntl def save(self): with open(self.storage_path, a) as lock_file: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) # 原子寫入內(nèi)容 with open(tmp_file, w, ...): json.dump(...) tmp_file.replace(self.storage_path) fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)但這里還有個(gè)微妙的問(wèn)題鎖的競(jìng)爭(zhēng)粒度是整個(gè)文件的寫入如果多個(gè)進(jìn)程同時(shí)修改的是不同層級(jí)的數(shù)據(jù)理論上是可以合并的。我圖省事直接全文件互斥了實(shí)測(cè)下來(lái)因?yàn)閏ontext本身寫得不頻繁性能損失可以忽略。如果你的項(xiàng)目寫得很頻繁就需要把鎖粒度細(xì)化到字段級(jí)。4.2 坑二單字段過(guò)期機(jī)制導(dǎo)致的連鎖混亂第二個(gè)坑是在我加字段級(jí)TTL之后才浮現(xiàn)的。最初版本我把TTL放到了level層級(jí)也就是session層整體2小時(shí)過(guò)期。后來(lái)改成字段級(jí)TTL后我發(fā)現(xiàn)一個(gè)新的問(wèn)題決定是否過(guò)期的時(shí)間基準(zhǔn)有問(wèn)題。一開(kāi)始我給的邏輯是now - created_at ttl就過(guò)期但這有個(gè)反例用戶在一個(gè)長(zhǎng)任務(wù)的剛開(kāi)始設(shè)置了待處理文件列表這個(gè)列表的created_at是一個(gè)小時(shí)前但任務(wù)還沒(méi)跑完。按created_at算它已經(jīng)過(guò)期了可實(shí)際上它一直被正常使用著。后來(lái)我改成now - updated_at ttl每次get命中就刷新updated_at這樣只要這個(gè)字段還在被持續(xù)使用它就會(huì)一直存活。這個(gè)邏輯目前驗(yàn)證下來(lái)比較合理但它也有代價(jià)一個(gè)長(zhǎng)期不用的上下文永遠(yuǎn)不會(huì)被徹底清掉因?yàn)槊看蝕et都會(huì)給它續(xù)命。所以我加了一個(gè)上限當(dāng)now - created_at超過(guò)某個(gè)最大值比如7天時(shí)強(qiáng)制認(rèn)為過(guò)期防止數(shù)據(jù)無(wú)限膨脹。實(shí)際排查過(guò)程中我用了一段腳本模擬了反復(fù)讀取同一個(gè)舊字段的情況確認(rèn)了updated_at確實(shí)被刷新也發(fā)現(xiàn)了created_at一直沒(méi)變。這類問(wèn)題只有在把預(yù)期行為和實(shí)際行為對(duì)照著看才容易暴露單純看代碼很難發(fā)現(xiàn)。4.3 坑三上下文日志里存了不該存的密鑰第三個(gè)坑比較嚇人。我在調(diào)試context-mode時(shí)經(jīng)常打印整個(gè)上下文看看數(shù)據(jù)對(duì)不對(duì)有一次發(fā)現(xiàn)打印內(nèi)容里包含了用戶臨時(shí)輸入的數(shù)據(jù)庫(kù)密碼。最開(kāi)始沒(méi)在意覺(jué)得反正本地開(kāi)發(fā)后來(lái)才意識(shí)到一旦這個(gè)模塊被集成到線上的自動(dòng)化腳本里并且開(kāi)啟debug日志就等于把密鑰明文寫到了日志文件里如果日志被上傳到日志中心那就是事故。修復(fù)方案是加一個(gè)脫敏層。具體在打印和序列化時(shí)需要識(shí)別常見(jiàn)的敏感字段名SENSITIVE_KEYS {password, secret, token, api_key} def safe_dump(data): if isinstance(data, dict): return {k: (*** if k.lower() in SENSITIVE_KEYS else safe_dump(v)) for k, v in data.items()} if isinstance(data, list): return [safe_dump(i) for i in data] return data然后把所有對(duì)外輸出改成走safe_dump底層的存量數(shù)據(jù)保持原樣。這里關(guān)鍵在于展示層脫敏存儲(chǔ)層不脫敏——如果你把存儲(chǔ)層的值都改了那后續(xù)需要用真實(shí)密碼時(shí)就會(huì)出錯(cuò)。這個(gè)坑給我一個(gè)更深的教訓(xùn)context-mode這類工具天然就是什么數(shù)據(jù)都往里塞的類型所以必須在設(shè)計(jì)階段就明確哪些數(shù)據(jù)碰都不能碰對(duì)于高敏感數(shù)據(jù)寧可完全不放進(jìn)去。4.4 修復(fù)后的驗(yàn)證方案我是怎么確認(rèn)問(wèn)題真解決的修完三個(gè)坑之后我整理了一組回歸測(cè)試用例專門用來(lái)驗(yàn)證context-mode模塊的穩(wěn)定性。這組用例現(xiàn)在被我放在項(xiàng)目的tests目錄下每次改動(dòng)后都會(huì)跑一遍并發(fā)測(cè)試同時(shí)啟動(dòng)8個(gè)進(jìn)程每個(gè)進(jìn)程往context里寫入不同的key重復(fù)100輪最后檢查JSON文件里是否所有key都完整存在。過(guò)期測(cè)試創(chuàng)建一條TTL為1秒的數(shù)據(jù)sleep 1.2秒后讀取確認(rèn)返回None并且該key從存儲(chǔ)中刪除。隱私測(cè)試用一個(gè)包含了password的假上下文跑一次打印確認(rèn)輸出中對(duì)應(yīng)字段是***。邊界測(cè)試key名稱為空字符串、value為None、value為中文和帶Unicode的字符都要能正常寫入和讀取。這些測(cè)試全部通過(guò)的時(shí)候心里才踏實(shí)下來(lái)。尤其是并發(fā)測(cè)試第一次跑就失敗后來(lái)加上進(jìn)程鎖才通過(guò)那種測(cè)試救了一命的感覺(jué)寫代碼的人都懂。5. 從context-mode到智能化在LLM會(huì)話場(chǎng)景中的擴(kuò)展5.1 把context-mode用在大模型交互上滑動(dòng)窗口和摘要搞定了基礎(chǔ)模塊之后我把它用在了另一個(gè)更貼近當(dāng)前熱點(diǎn)的場(chǎng)景大語(yǔ)言模型LLM會(huì)話的上下文管理。很多人問(wèn)為什么AI聊天工具不能無(wú)限記住所有對(duì)話核心原因就是上下文窗口有上限。context-mode在這里的價(jià)值是可以設(shè)計(jì)一種滑動(dòng)窗口 摘要的組合策略。具體做法是維護(hù)一個(gè)會(huì)話歷史列表新消息不斷加入。當(dāng)列表長(zhǎng)度超過(guò)N條或者總token數(shù)超過(guò)閾值時(shí)我們?nèi)∏懊嫒舾蓷l消息做一次摘要然后把摘要作為一個(gè)新的系統(tǒng)消息再丟掉最老的消息。這樣實(shí)際上形成了一種滾動(dòng)上下文。我把這個(gè)邏輯復(fù)用了之前的ContextManager只是在session層里存了一個(gè)chat_historyMAX_HISTORY 10 def append_message(role, content): history ctx.get(session, chat_history) or [] history.append({role: role, content: content}) if len(history) MAX_HISTORY: old_part history[:-MAX_HISTORY // 2] recent_part history[-MAX_HISTORY // 2:] old_text \n.join(f{m[role]}: {m[content]} for m in old_part) summary llm_summarize(old_text) # 調(diào)用模型生成摘要 ctx.set(session, chat_summary, summary) ctx.set(session, chat_history, recent_part) else: ctx.set(session, chat_history, history)這套方案的關(guān)鍵是摘要的更新時(shí)機(jī)。如果每來(lái)一條新消息就跑一次摘要成本太高我的經(jīng)驗(yàn)是等舊部分累計(jì)超過(guò)閾值時(shí)再整體生成一次摘要。另外要注意摘要本身也應(yīng)該有TTL因?yàn)樗4娴氖菚?huì)話的臨時(shí)狀態(tài)會(huì)話結(jié)束或被新摘要替代后就不再需要了。5.2 后續(xù)還能怎么擴(kuò)展共享上下文、權(quán)限控制、可視化調(diào)試現(xiàn)在這個(gè)context-mode模塊已經(jīng)能穩(wěn)定運(yùn)行了但還談不上完善。我自己整理了三個(gè)明確的擴(kuò)展方向如果你也在做類似項(xiàng)目可以參考。第一個(gè)方向是共享上下文。目前每個(gè)用戶各自維護(hù)一份context文件如果想在團(tuán)隊(duì)里共享項(xiàng)目級(jí)上下文就需要引入同步機(jī)制。我的初步思路是給每條context記錄加一個(gè)owner字段拉取時(shí)先檢查權(quán)限只返回自己有權(quán)限的部分。這樣可以做到項(xiàng)目組共享構(gòu)建參數(shù)個(gè)人私有配置不泄露。第二個(gè)方向是權(quán)限控制。這跟上面的共享是配套的。context里往往包含敏感信息比如數(shù)據(jù)庫(kù)連接串、臨時(shí)密鑰。改造方向是在set/get的接口中加入一個(gè)權(quán)限校驗(yàn)層按照用戶角色判斷當(dāng)前操作是否被允許。最簡(jiǎn)單的實(shí)現(xiàn)是建一個(gè)權(quán)限表每個(gè)key對(duì)應(yīng)一個(gè)可訪問(wèn)的角色列表復(fù)雜一點(diǎn)的方向是做成與現(xiàn)有SSO/身份系統(tǒng)對(duì)接。第三個(gè)方向是可視化調(diào)試。前端工程師調(diào)試時(shí)最想看的是上下文樹(shù)的實(shí)時(shí)變化。我已經(jīng)用Python做了個(gè)簡(jiǎn)單的Web頁(yè)面把ContextManager暴露成一個(gè)HTTP接口用WebSocket推送變更事件界面上能看到global/session/project三層的數(shù)據(jù)新增、過(guò)期、刪除。這個(gè)擴(kuò)展對(duì)排查為什么某次操作行為異常特別有用因?yàn)槟隳芸吹匠绦虻降子涀×耸裁?。把context-mode從一個(gè)小模塊擴(kuò)展成完整的上下文管理框架這條路其實(shí)很長(zhǎng)。但至少起步階段的方向是清晰的先保證數(shù)據(jù)結(jié)構(gòu)合理再確保并發(fā)安全最后再往智能化和協(xié)作方向走。我在實(shí)際項(xiàng)目中體會(huì)最深的一點(diǎn)是context-mode看起來(lái)像是個(gè)不起眼的輔助功能但對(duì)用戶體驗(yàn)的提升是實(shí)實(shí)在在的。它讓工具從一個(gè)每次都要重新認(rèn)識(shí)你的陌生人變成了記得你習(xí)慣的同事。開(kāi)發(fā)過(guò)程中踩過(guò)的這三個(gè)坑——并發(fā)覆蓋、過(guò)期混亂、隱私泄漏——幾乎每一個(gè)都值得單獨(dú)寫篇文章但總結(jié)下來(lái)核心原則也就三條上下文要分層、過(guò)期要按字段、敏感要脫敏。記住這三點(diǎn)你也能做出一個(gè)穩(wěn)定好用的context-mode。