ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI 会话状态管理:用 Markdown 文件协议替代缓存与向量库

AI 会话状态管理:用 Markdown 文件协议替代缓存与向量库 去年冬天我接手的一个内部 AI 助手项目出了次事故一位同事跟助手聊了四十分钟把一份合同的风险点逐条过了一遍结果页面刷新上下文全没了。我去翻那一轮会话的数据——缓存里有 key但值是一个被序列化之后又截断的字符串工具调用的中间结果散在另一个 key 里还有一部分因为过期策略已经蒸发了。最后靠什么把内容拼回来的靠那个助手在操作本地文件时顺手写下的一个.md文件。那件事之后我做了个决定把 AI 会话的状态从键值缓存 向量库这条路上撤回来改成以Markdown 文件作为唯一可信数据源再用一份自己定的文件协议把目录、命名、字段、读写时机全部约束死。这套东西我用了大半年会话文件从几百条涨到一万多条检索延迟没有变差反而因为整个过程可读、可 diff、可回滚排障时间从半天降到了五分钟。下面这套思路适合正在做 AI 应用、Agent 或者自动化工作流的人——不管你是用 n8n、Dify、Coze 这类编排工具搭流程还是自己写代码直接调模型接口。它不要求你引入任何新服务只要求你愿意在文件怎么放、字段怎么定这件事上多花两个小时。1. 把会话状态从数据库搬回 .md到底图什么1.1 会话里其实混着三种性质完全不同的数据一场 AI 会话产生的数据粗看是一堆消息细看至少分三类而且它们的生命周期和写入模式完全不一样。第一类是事实。比如这个用户偏好中文回复他所在的项目叫 Alpha他讨厌回答里出现枚举符号。这类数据的特征是变化极慢、读取极频繁、永远只关心最新值天然适合覆盖写——新值来了就把旧值盖掉历史值没有保留价值。第二类是过程。这一轮用户说了什么、模型判断该调哪个工具、工具返回了什么、中间重试了几次。这类数据只增不改单次价值低但排障时价值极高天然适合追加写。第三类是结论。这一轮谈出了什么共识、下一步做什么、哪些还没确认。这类数据产出频率低、被反复读取、格式必须稳定因为它要被下一个环节直接消费——可能是人也可能是下一轮模型。我最早的做法是把三类全部塞进一个 JSON整体存成缓存的一个 value。结果是灾难事实类要频繁更新每次更新都得把整个 JSON 读出来、改一处、再写回去过程类让 JSON 迅速膨胀到几十 KB结论类藏在某个嵌套数组深处人根本不想打开看。更麻烦的是只要有一次写入超时或者进程被 kill你得到的就是一个语法都不完整的字符串连读出来看看都做不到。拆开之后事情变得很朴素事实写成memory/facts.md里的条目过程追加进logs/tool-calls.md结论写进每条会话自己的## 结论段。三者互不干扰各自的写入模式也都是它们最舒服的那种。判断一份数据该用什么存储先别看容量和 QPS先看它是覆盖型还是追加型。覆盖型要的是事务和唯一性追加型要的是顺序和不可变性。这两件事混在一个容器里早晚出事。1.2 向量库擅长找得到不擅长读得懂向量数据库解决的是什么问题是我有一百万段文字想找出跟这句话语义最近的十段。它做的是近似匹配输出的是碎片。会话状态需要的恰恰相反我需要精确地知道这条待办是谁在什么时候定的上一轮讨论到哪一步了这个字段的取值是 A 还是 B。这些问题的答案必须确定不能是大概相近。打个比方向量库像把一本说明书撕成纸条塞进抽屉你问它第三章讲了什么它能抽出几张语义相关的纸条但纸条之间的顺序、边界、从属关系全没了。你拿到的是一堆相关材料不是一份文档。很多 Agent 的幻觉根子就在这儿——模型拿到的是被打散、被截断、失去标题上下文的片段它只能靠猜来补全原文猜错了就是幻觉。还有一层现实问题切分chunking本身就是有损的。你怎么切决定了会丢什么。切得太碎这是一条待办这个语义标签很可能被切到隔壁块去切得太大检索精度直线下降。我试过四五种切法没有一种能让结构化字段和语义检索同时舒服。Markdown 的好处是它本身就是带层级结构的纯文本。## 结论下面的内容天然就是一个语义边界明确的块不需要靠字符数去猜该切在哪。你既可以用正则精确捞出某一段也可以把整份文档喂给模型让它理解两种用法都不别扭。1.3 文件作为状态载体的四个天然优势可 diff。每次写入都是一次文本变化git diff一跑AI 这一轮到底改了什么一行一行摆在你面前。这在调试 Agent 的时候是决定性的——你不需要打印日志、不需要加断点直接看文件就行。可回滚。模型写坏了内容git checkout或者把上一版复制回来两秒钟的事。缓存里你没法回滚你只能眼睁睁看着脏数据覆盖了干净数据。可人工接管。这是我最看重的一条。系统跑飞的时候人能直接打开文件、用编辑器改、改完保存系统接着跑。不需要写管理后台不需要临时开个 SQL 客户端不需要提工单。文件管理器就是你的后台。零依赖。不用装服务、不用连端口、不怕重启、不怕版本升级导致的协议不兼容。整个存储层就是打开文件、写入、关闭这三个系统调用。备份就是复制文件夹迁移就是打包压缩。方案精确结构化查询人类可读版本回溯启动成本适合的会话数据键值缓存弱否无需常驻服务短期热状态、限流计数关系数据库强否依赖备份需常驻服务强关系、需要事务的场景向量数据库语义近似否无需常驻服务加模型超大规模非结构化语料Markdown 文件靠正则与解析是原生支持零中等规模、需要人参与的场景2. 协议先行先把目录和命名定死再谈写入2.1 一份文件协议必须回答的五个问题Markdown 最大的优点是自由最大的缺点也是自由。没有协议约束你写三个月就会得到一堆标题层级混乱、frontmatter 缺字段、日期格式三种混用的文件然后你就得写一堆容错代码去伺候这些历史包袱。所以协议的价值不在自由在约束。我这份协议逼着自己回答五个问题谁写分两种情况。结构化的部分frontmatter、固定标题段由程序写程序负责格式绝对正确自由内容正文段落由模型写程序只做清洗不做改写。这条界线很重要——让模型去拼 YAML 头你迟早会遇到缩进错乱。写哪里路径必须能从会话 ID 完全推导出来不允许出现这个文件该放哪的判断。路径推导是纯函数不依赖任何外部状态。写什么格式哪些字段必填、哪些段必须存在、标题用什么措辞全部写死在协议里。模型只能往指定段落里填内容。什么时候写会话结束时写一次主文件工具调用后追加一行日志每 N 轮更新一次索引。触发点固定不搞实时同步。冲突怎么办默认单写者多写者场景走锁文件加冲突另存绝不做自动合并。2.2 目录蓝图sessions / memory / artifacts / logsworkspace/ index.md # 全局索引一表一行只追加 sessions/ 2025-06-11/ 1432-a1b2c3-合同风险梳理.md 1608-d4e5f6a-接口联调记录.md memory/ facts.md # 覆盖型事实条目式 preferences.md # 用户偏好 entities/ project-alpha.md # 按实体拆分避免单文件膨胀 artifacts/ 2025-06-11/ contract-risk-notes.md # 模型产出的独立文档 logs/ tool-calls.md # 追加型日志 errors.mdindex.md是唯一的入口只负责导航不存正文。sessions/按日期分目录一天一个文件夹——单个目录里放几万个文件不光文件管理器卡某些文件系统的目录项读取也会变慢。memory/放跨会话共享的事实按实体拆成多个文件避免所有人都往一个文件里写导致冲突。artifacts/放模型产出的、不属于会话对话本身的独立文档比如一份整理好的会议纪要。logs/纯追加只写不读除非排障。2.3 命名与 ID时间戳、语义前缀、短哈希文件名我固定成三段时间戳-短哈希-语义标题.md。时间戳用HHMM当天内的时分不用完整年月日因为日期已经在目录名里了重复一遍只是浪费。短哈希取会话 ID 的 SHA-1 前七位作用是在同一分钟内并发创建多个会话时避免撞名同时给程序一个稳定可查的键。语义标题是人看的用中文用连字符代替空格长度控制在二十个字以内。为什么不直接用 UUID因为我需要在文件管理器里找东西。上周跟谁聊过接口联调这种记忆是模糊的你让我在一堆a3f9c2e1-...md里翻等于没有名字。而1608-d4e5f6a-接口联调记录.md一眼就能扫到。命名规则定死之后从会话 ID 到文件路径就是一道算术题没有任何分支判断。这条看起来不起眼但它让该写哪里这个问题彻底消失了代码里也就少了一大类 bug。3. Frontmatter 是表结构正文标题是字段名3.1 必备字段与可选字段清单每条会话文件的头部是一段 YAML frontmatter。它承担的角色等价于关系表里的列定义。--- id: 20250611-1432-a1b2c3 ts: 2025-06-11T14:32:0708:00 role: assistant parent: null tags: [合同, 风险, 法务] model: internal-chat-v3 turns: 14 tokens_in: 18240 tokens_out: 3210 status: closed summary: 梳理了合同中的三条付款风险与两条违约条款风险待法务确认第二条。 ---几个字段的取值我踩过坑值得单独说。ts必须带时区偏移不然后面按时间排序会乱。tags用数组而不是逗号分隔的字符串因为解析成数组之后做集合运算很自然字符串还得手动 split 再去空格。status我用了open/closed/conflict三个值conflict专门留给并发冲突待人工处理的文件。summary是最关键的字段。它由一次单独的模型调用生成要求不超过八十个字写清楚谈了什么和留下了什么待办。索引文件和上下文装配都依赖它——有了它你才可能在只读两百个字的情况下判断这条会话要不要展开看。字段类型必填说明id字符串是时间戳加短哈希与文件名对应tsISO 8601 带偏移是会话创建时间排序唯一依据role枚举是这条记录的主导方便于区分人机parent字符串或空否派生会话的父 ID支持会话树tags字符串数组是用于检索控制在五个以内tokens_in / tokens_out整数否用于成本统计status枚举是open / closed / conflictsummary字符串是不超过八十个字的摘要3.2 用 ## 标题当列把一份会话变成一张宽表正文部分我强制使用固定的二级标题## 用户输入 ## 我的判断 ## 工具调用 ## 结论 ## 待办这五个标题就是五个列。每条会话文件是一行整个sessions/目录就是一张表。解析的时候按##切分取到标题归一化之后作为 key段落内容作为 value一条记录就被结构化出来了。为什么用标题而不是列表或者 JSON 块三个理由。第一标题能被 Markdown 渲染器折叠和生成目录人看起来舒服。第二标题在纯文本里视觉权重最高扫一眼就知道有没有这个字段。第三解析逻辑简单到离谱——按行扫描遇到^##\s就切换当前字段。用 JSON 块的话模型经常漏个逗号或者多写个括号你就得写一堆容错。让模型往指定标题下填内容比让模型输出一段合法 JSON可靠得多。前者错了顶多是内容跑偏后者错了整个文件报废。3.3 解析容错缺字段、类型漂移、中文标点即使协议定死了实际跑起来还是会遇到三类脏数据必须提前处理。frontmatter 缺失。模型偶尔会把整份文件重写一遍把这点头部吃掉。我的处理是解析时如果检测不到以---包围的头部就用文件名反推id和ts其余字段给默认值同时把status标成conflict并记一条日志。类型漂移。YAML 里status: open是字符串但turns: 14是带引号的字符串turns: 14是整数。强制类型转换时必须包一层 try转换失败就给默认值而不是抛异常炸掉整条记录。中文标点。全角冒号混进 YAML 的 key 后面解析器会当成值的一部分。清洗规则是YAML 头部分只允许半角冒号作为键值分隔符遇到全角冒号直接判定为格式异常走修复流程。import re, yaml HEAD_RE re.compile(r^---\s*\n(.*?)\n---\s*\n, re.S) SECTION_RE re.compile(r^##\s(.?)\s*$, re.M) def parse_session(text: str, fallback_id: str): m HEAD_RE.match(text) meta {} body text if m: try: meta yaml.safe_load(m.group(1)) or {} except yaml.YAMLError: meta {} body text[m.end():] else: meta {id: fallback_id, status: conflict} sections, current, buf {}, None, [] for line in body.splitlines(): h re.match(r^##\s(.?)\s*$, line) if h: if current: sections[current] \n.join(buf).strip() current, buf h.group(1).strip(), [] else: buf.append(line) if current: sections[current] \n.join(buf).strip() meta.setdefault(status, open) meta.setdefault(tags, []) return meta, sections4. 一次会话的完整读写链路4.1 开场装配索引、最近若干条、相关记忆会话开始时程序要做的是把上下文拼出来。顺序是固定的三步。第一步读index.md它是一张 Markdown 表格每行一条会话列包括日期、标题、标签、摘要。按标签命中度加时间新鲜度排序取前若干条候选。这一步全程正则解析不调模型几十毫秒就完成。第二步把候选记录的summary拿出来判断哪些值得展开成全文。判断规则可以简单到标签完全命中就展开也可以让模型跑一次小结筛选。我一般限制展开不超过三条因为全文进上下文非常吃 token。第三步读memory/facts.md和命中的memory/entities/*.md。这些是跨会话的事实优先级最高放在上下文最前面。拼出来的提示词结构大致是这样[长期事实] 来自 memory/facts.md 与命中的实体文件 [近期会话摘要] 2025-06-11 14:32 合同风险梳理 —— 梳理了三条付款风险…… [本轮会话全文] 来自最近三条 session 的正文 [当前用户输入] ……这样拼的好处是模型永远先看到稳定的长期事实再看近期脉络最后才处理当前问题。多轮对话里最容易出的把上周的结论当成今天的共识这类错很大一部分能靠这个顺序压下去。4.2 收场落盘原子写、索引更新、摘要生成会话结束时写盘分四步顺序不能乱。第一模型生成summary字段。这一步单独发一次请求提示词只要两个约束不超过八十个字、必须包含待办事项。不需要它总结得多漂亮只需要它区分得出聊了什么和要做什么。第二渲染完整文件内容frontmatter 五个固定段落写进临时文件。临时文件必须是同目录下的比如xxx.md.tmp不能写到/tmp因为跨文件系统重命名不是原子操作。第三fsync之后rename覆盖目标文件。这是 POSIX 保证的原子替换——要么看到旧文件要么看到新文件永远不会看到半个文件。这一步是整套方案里我唯一不允许省略的操作。第四往index.md追加一行同时往logs/tool-calls.md追加本轮的工具调用记录。追加不重写因为重写索引会带来并发问题而追加是天然安全的。import os, tempfile def atomic_write(path: str, content: str): d os.path.dirname(os.path.abspath(path)) fd, tmp tempfile.mkstemp(dird, suffix.tmp) try: with os.fdopen(fd, w, encodingutf-8) as f: f.write(content) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) # 原子替换 finally: if os.path.exists(tmp): os.remove(tmp)别小看os.replace这一步。我早期直接open(path, w)覆盖遇到过一次进程在写了一半时被重启文件被截断解析器全线报错。换成原子替换之后这类事故再没出现过。4.3 上下文预算全文、摘要、引用三级策略Token 是有价格的上下文窗口是有上限的。我的做法是给三类内容定死预算比例。内容类型策略典型预算占比说明长期事实全文内联15%量小、价值高必须完整近期会话摘要内联25%只放 summary需要时再展开历史会话引用不内联0%只在提示词里给出文件名和标题当前输入全文内联60%用户这次说的东西优先引用不内联是这套方案里最省 token 的一招。提示词里只写如果需要可以要求读取sessions/2025-06-05/1012-b2c3d4e-需求评审.md把是否展开的决定权交给模型。实测下来模型很少会主动要求读取无关的历史文件但需要的时候它知道去哪找。这比一股脑把所有历史塞进上下文要聪明得多。5. 并发、锁与冲突合并5.1 单写者原则与锁文件的实现整套协议的第一原则是单写者同一时刻只有一个人进程能写。所有并发问题说到底都是因为违反了这条。实现上我用锁文件而不是操作系统的文件锁 API。原因是文件锁在不同语言、不同平台上的行为差异太大而且进程崩溃后可能留下死锁。锁文件我自己控制生命周期心里有数。import os, time, json def acquire_lock(lock_path: str, ttl: int 60, wait: float 5.0): deadline time.time() wait while time.time() deadline: try: fd os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.write(fd, json.dumps({ pid: os.getpid(), expire: time.time() ttl }).encode()) os.close(fd) return True except FileExistsError: # 检查是否为过期锁 try: with open(lock_path) as f: info json.load(f) if time.time() info.get(expire, 0): os.remove(lock_path) continue except Exception: pass time.sleep(0.2) return False关键点是O_CREAT | O_EXCL这是操作系统层面保证的不存在才创建两个进程同时抢只有一个能成功。锁里写进 pid 和过期时间过期时间是为了防止进程被 kill 之后锁永远不释放。这个过期锁自动清理机制我建议一定要加——不加的话某天早上你会发现整个系统停摆原因只是昨晚有个进程异常退出留下了一个锁文件。5.2 冲突检测修改时间加内容哈希锁只能防住明知有并发还硬写的情况。真正麻烦的是慢速写读文件、加工、写回中间隔了几秒钟这期间别人已经改了。这就是典型的丢失更新。我的检测方式是写之前把两个信息再核对一遍文件的mtime和内容的哈希值。读的时候记下来写之前重算只要有一个对不上就判定冲突。import hashlib, os def fingerprint(path: str): st os.stat(path) with open(path, rb) as f: h hashlib.sha256(f.read()).hexdigest() return st.st_mtime_ns, h def guard(path: str, snapshot): now fingerprint(path) if now ! snapshot: raise RuntimeError(fconflict detected on {path})只看mtime是不够的因为文件系统的时间戳精度有限同一秒内的两次修改可能看不出区别只看哈希也是不够的因为改回原样这种情况哈希一样但确实发生过写。两个一起看漏判概率就低到可以忽略了。5.3 冲突兜底另存副本等人工裁决检测到冲突之后我不做自动合并。这一点上我是很坚决的会话内容不是代码自动合并会造出谁也没说过的话。处理方式是把当前版本另存一份带.conflict.md后缀的副本frontmatter 里的status标成conflict同时在logs/errors.md追加一条记录写清楚冲突文件的路径和两个版本的时间。然后由人工打开两份文件对比。人工裁决这件事听起来很麻烦但它发生的频率比你想的低得多。我配置了单写者加锁之后一周大概只会遇到一两次而且大多集中在批量导入脚本和实时会话同时跑的时候。找到规律之后把批量任务挪到低峰期执行冲突基本就没了。6. 检索层先 grep后向量6.1 索引文件长什么样index.md是一张 Markdown 表格每行一条会话记录| 日期 | 标题 | 标签 | 摘要 | 路径 | | --- | --- | --- | --- | --- | | 2025-06-11 | 合同风险梳理 | 合同,风险,法务 | 梳理了三条付款风险…… | sessions/2025-06-11/1432-a1b2c3-合同风险梳理.md | | 2025-06-11 | 接口联调记录 | 接口,联调 | 确认了三个字段的格式…… | sessions/2025-06-11/1608-d4e5f6a-接口联调记录.md |为什么索引用表格而正文用标题因为索引的用法是扫正文的用法是读。表格天然是横向的grep出来一行就包含全部必要信息标题天然是纵向的适合让人和模型逐段理解内容。用错了地方两边都不舒服。索引只追加不重写还有个副作用如果同一会话后面又更新了内容索引里会有两行。我的处理是解析时按id做一次去重保留最后一行。这样索引文件本身可以保持纯追加的简单性去重逻辑放在读取侧。6.2 ripgrep 与正则的实战写法日常检索我用rgripgrep因为它默认忽略.gitignore里的文件、支持多线程、速度比grep快得多。几个我天天在用的命令按标签找会话rg --md -l tags:.*合同 sessions/ | sort -r | head -20--md让它按 Markdown 处理-l只输出文件名管到sort -r之后取最近的二十条。按 frontmatter 字段精确匹配状态rg -l ^status:\s*conflict\s*$ sessions/捞某个会话文件的特定段落用-A带上下文行rg -A 30 ^## 结论 sessions/2025-06-11/1432-a1b2c3-合同风险梳理.md统计一周内所有待办段落rg -A 10 ^## 待办 sessions/2025-06-0[4-9] sessions/2025-06-1[01]这些命令的组合能力是纯文件方案最大的底气。你不需要写任何查询接口只需要熟悉几个正则。实测在一万两千个文件、总共八十兆文本的目录里一条带正则的rg命令平均耗时不到两百毫秒。6.3 什么时候该把向量数据库请回来纯文件方案不是万能的。我给自己的判断标准是下面这张表触发条件是否引入向量库说明文件数少于五万、查询以字段和标签为主不需要正则足够延迟更低需要语义相近模糊召回需要比如上次聊过类似的问题需要跨语言检索需要中英混杂时正则会漏只做精确匹配和统计不需要关系库或直接解析都行单机检索延迟要求低于五十毫秒谨慎文件扫描在冷缓存时会慢我的实际做法是双轨文件是唯一可信数据源向量库只是一个派生索引随时可以从文件重建。也就是说向量库挂了不影响主流程重建一次索引大概十分钟能接受。这条边界一定要划清楚否则你迟早会把某条只在向量库里存在的数据当成宝贝然后就再也删不掉它了。7. 与自动化工作流、外部工具的对接7.1 旁路同步到关系库做统计不要做写入文件方案做统计很别扭。你想知道这个月每个标签的会话数量趋势用rg加awk能凑出来但下一次想换个维度就又要重写一遍。我的做法是加一条单向旁路定时任务扫描sessions/解析 frontmatter把元数据同步进一张关系表或者轻量数据库里。这条链路是严格单向的——文件是源数据库是副本任何写操作都不允许反向流回文件。为什么这么坚持因为一旦允许双向写你就得处理两边都改了怎么办这个经典难题而解决它需要的时间远超它带来的收益。统计报表需要的是新鲜度延迟十五分钟完全可以接受。同步脚本本身很简单核心逻辑就是把前面写好的parse_session跑一遍然后批量upsert。难点全在脏数据处理上——历史上总有那么几十个文件格式不规整脚本必须能跳过它们而不是整个跑挂。7.2 导出链路md 转 html、docx、pdf 时的图片路径坑Markdown 作为中间格式最大的好处是导出方便。pandoc一条命令就能转成 HTML、docx、LaTeXpandoc input.md -o output.html --standalone --toc pandoc input.md -o output.docx但这里有个坑我第一次踩的时候查了两个小时图片的相对路径。Markdown 里写![](./images/a.png)在文件所在目录预览完全正常一导出到别的地方就全是裂图。原因是导出工具解析的是相对于输出文件位置的路径而不是相对于源文件。解决办法有两个导出前把图片路径批量替换成绝对路径或者直接把图片内联成 base64。前者适合本地使用后者适合需要单文件分发的场景。# 把相对路径替换成绝对路径后再导出 sed -E s#\]\(\./images/#](/abs/path/to/images/#g input.md tmp.md pandoc tmp.md -o output.docx转 PDF 的话得先转 HTML 再交给渲染引擎排版中间会额外引入字体和分页的问题。如果只是内部用我建议直接导出 HTML浏览器打印成 PDF 就够了省掉一大串依赖安装。7.3 用 Git 做版本与备份整个工作区我建议直接初始化成 Git 仓库。理由有三条一是版本回溯天然免费二是git diff帮你排查模型到底改了什么三是推送到远端就等于异地备份。但要注意几件事。.gitignore里一定要排除掉临时文件、大体积的artifacts/二进制、以及锁文件。提交频率不要太密我配的是每小时一次自动提交加上会话结束时的显式提交。历史记录用浅克隆或者定期 squash否则三个月后仓库会膨胀到几个 G——虽然文本文件压缩率很高但也架不住一天几百次提交。还有一点不要用 Git 去同步正在写入的文件。自动提交脚本必须先判断锁文件是否存在存在就跳过这一轮。不然你会得到一堆半截内容的提交git diff里全是噪音。8. 踩坑清单与加固方案8.1 YAML 头里的冒号、井号与中文标点这是最高频的坑没有之一。凡是标题里带冒号、带井号、带中文全角标点的直接裸写就会炸。# 错误写法解析直接失败 title: 会议纪要: 第一阶段 summary: 讨论了三件事 # 预算、排期、人力第一行的冒号会被当成新的键值分隔符第二行的井号会被当成注释。正确写法是加引号title: 会议纪要: 第一阶段 summary: 讨论了三件事预算、排期、人力我的加固方案是在写盘之前做一次统一的字段清洗所有字符串类型的字段只要包含冒号、井号、引号、尖括号、全角标点一律用双引号包裹并把内部的双引号转义。清洗函数只要十几行能省掉后面无数次的排查。别指望模型会自觉遵守引号规则。它上一轮守规矩下一轮就忘了。所有格式约束必须由程序在写盘前强制执行。8.2 模型擅自改标题层级导致解析断链第二个高频坑。协议要求五个二级标题但模型有时候会写成### 结论或者写成结论甚至把## 待办改成了## 后续事项。这几种情况下按固定标题做的解析就会漏掉整段内容。我的处理分两层。第一层是归一化解析时把标题做一次清洗——去掉所有#、去掉首尾空白、去掉中英文冒号、去掉冒号后面的说明文字然后跟一个同义词映射表比对。待办、后续、下一步、to-do全部映射到同一个 key。第二层是收窄模型的职责。这是更根本的解法不要给模型看整份文件让它续写而是只给它一个段落名让它只输出这个段落的内容由程序拼装。模型只负责填内容不负责画骨架。自从改成这样标题层级出错的概率从每周几次降到了几个月一次。8.3 时间戳时区与排序错乱第三个坑比较隐蔽。一开始我的ts用的是本地时间格式2025-06-11 14:32:07看着挺好。问题是当服务器和开发机不在同一时区时同一批数据排出来的顺序不一样。更麻烦的是跨夏令时的地区某一天会多出或少掉一小时。改成 ISO 8601 带偏移就彻底解决了ts: 2025-06-11T14:32:0708:00排序的时候全部转成 UTC 时间戳再比较展示的时候再转回本地。这一条改动很小但它让按时间排序这件基础操作变得确定。凡是跟时间有关的协议字段我现在的态度是宁可写长一点也不要省那个时区。8.4 单文件膨胀与写入截断最后说说文件大小。一条会话聊得久了正文可能到几十 KB加上工具调用日志一路追加单个文件能膨胀到一两百 KB。这时候有两个问题一是模型读整份文件太贵二是解析变慢。我的阈值是正文超过六十 KB 就拆。拆的方式不是按字数硬切而是按讨论的主题拆——同一份sessions/记录里如果出现多个明显独立的议题就拆成多条会话用parent字段串起来形成一个会话树。这样既控制了单文件体积又保留了脉络。至于写入截断前面讲过的原子替换就是专门治这个的。除此之外我还会在每次写完之后立刻做一次自检重新读文件、跑一遍解析、确认五个段落都在、确认 frontmatter 能被yaml.safe_load解析。自检失败就回滚到上一版并在logs/errors.md里记一笔。这一步多花几十毫秒但能保证你永远不会在第二天早上面对一个坏文件。这套东西我用了大半年最大的感受是它省下的不是存储成本是沟通成本。会话记录放在那里谁都能打开看谁都能改改完系统认。产品和研发讨论的时候不用再问这个逻辑在哪实现的直接一起看文件。这个体验是任何需要连客户端才能查的数据都换不来的。如果你的会话量还在几万条以内、并且需要人参与我建议先别急着上向量库拿一个下午把目录结构和 frontmatter 定下来用rg顶一阵子。等你真的遇到了语义召回的需求再补一个可随时重建的派生索引也不迟。反过来先把服务搭起来再想数据结构后面搬家的代价会大得多。
返回列表