ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI Agent长期记忆外置:Hermes对接OpenViking服务端实战

AI Agent长期记忆外置:Hermes对接OpenViking服务端实战

项目标题是“hermes 外部记忆配置openviking,服务端”,可用输入只有标题,其余字段为空。按标签看,这属于 AI Agent 记忆外置类的配置实战。接下来我就按这个方向展开。

前几天帮一位朋友排查他那个跑了两周的对话代理,症状很典型:前两天回答还挺聪明,第三天开始前言不搭后语,重启进程之后更是彻底失忆,之前聊过的偏好、约定好的输出格式、甚至上一轮刚确认过的参数全没了。他一开始以为是模型退化,换了两三个模型供应商都没解决,最后发现问题根本不在模型上——记忆全压在进程内存里,进程一重启就清零,上下文一长就被挤掉。这类问题在把 Hermes 当主力 Agent 框架用的人里非常常见,解法也不复杂:把记忆从 Hermes 进程里搬出去,落到一个独立的、可持久化的外部记忆服务上,比如用 OpenViking 做服务端,Hermes 只负责读写。下面我就把从零搭这套东西的完整链路拆开讲,包括服务端怎么部署、Hermes 侧怎么接、召回为什么查不准、上线后怎么盯。适合已经跑通过 Hermes 基础对话、正准备把它推到长期运行场景的同学参考,纯新手也能看懂,我会把每个参数为什么这么填都讲清楚。

1. 为什么非要把 Hermes 的记忆搬到 OpenViking 服务端

1.1 内置记忆在长期运行下的三个固定短板

很多人第一次用 Hermes 会觉得它自带记忆挺省事:对话历史自动进上下文,简单的偏好它能记住。但只要你的使用场景跨过"一次性问答"这条线,进入"连续多天、多任务、多入口"的状态,内置记忆基本都会崩,而且崩的方式高度一致,主要集中在三个地方。

第一个是上下文挤占。Hermes 的默认行为是把近期对话塞进上下文窗口,窗口就那么大,历史越多,留给当前任务的空间越少。你会观察到一个很反直觉的现象:聊得越久,回答质量越差。不是模型变笨了,是真正有用的系统指令、工具定义被历史记录挤到了边缘,注意力被稀释。我见过最夸张的一个案例,一个用户连续聊了四百多轮,最后模型连自己该用什么格式输出都忘了,因为它前面那几百轮闲聊把指令区顶掉了。

第二个是进程重启即失忆。内存态记忆不做持久化,容器一滚动、服务一重启、机器一宕机,全部清零。这在开发阶段不明显,因为你会频繁重启,早就习惯了每次重新交代背景;但一旦上线跑长任务,一次意外重启就意味着用户要重新解释一遍他十天前说过的东西,体验直接崩盘。

第三个是多实例之间不共享。为了让服务扛得住并发,Hermes 通常会起多个进程或者多个副本。内置记忆是进程私有的,用户在 A 副本上说过的偏好,请求落到 B 副本上就查不到,表现为“时灵时不灵”。这种间歇性失忆最难排查,因为日志里一切正常。

这三个短板的共同根源是同一个:记忆的存储生命周期被绑定在了 Agent 进程的生命周期上。要解决,就必须把记忆的读写抽出来,交给一个独立于 Agent 进程、可持久化、可被多实例共享的服务。这正是 OpenViking 这类外部记忆服务端要承担的角色。

1.2 记忆外置之后,Hermes 那边到底少做了什么

把记忆搬到服务端,不是说 Hermes 就变成一个无状态转发器了,它的职责发生了变化,但没消失。理解这个分工,后面配置的时候才不会瞎填参数。

改造之后,Hermes 侧只保留三件事:决定什么时候写、决定什么时候读、决定读到的东西怎么塞进上下文。真正的存储、索引、向量化、相似度计算、过期清理,全部下沉到 OpenViking。你可以把它理解成:Hermes 是那个负责“记什么”的助理,OpenViking 是那个负责“存在哪、怎么快速翻出来”的档案室。

这个分工带来的直接好处是记忆容量和上下文窗口解耦了。以前记忆上限受限于窗口大小,现在记忆可以无限长,每次只把最相关的几条捞出来塞进去,窗口占用反而变小了。我实测过同一个任务链路,接入外部记忆之后单次请求的输入 token 数下降了大约四成,因为不用再拖着几十轮历史跑了,只带检索出来的三五条摘要就够。

还有一个容易被忽略的收益:记忆变成了可观测的资产。记忆存在服务端,你可以直接查、直接改、直接删。用户说“你记错了,我不是这个偏好”,你打开服务端把那条记录改掉就行,不用像以前那样想办法在对话里“覆盖”它——而覆盖这件事在上下文里其实做不干净。

1.3 什么时候不值得折腾这套东西

我不想把话说得太满,外置记忆不是无脑上就对了。如果你的场景满足下面这几条中的大部分,那老老实实用内置记忆更划算,别给自己找运维负担。

场景特征建议原因
单次会话、用完即弃的问答工具不上外部记忆生命周期本来就短,外置只会增加一次网络往返
只有单实例、不重启的本地脚本可以暂缓进程不重启就不丢,收益不明显
记忆量小、且都是短偏好可以暂缓直接写进系统提示词更简单直接
需要跨会话、跨天、跨实例必须外置这是内置记忆的硬伤区
需要人工审核、修改记忆内容必须外置服务端才有可审计的存储
记忆条目会增长到上千条以上必须外置靠上下文塞不下,必须走检索

判断标准其实就一句话:记忆的存活时间是否超过进程存活时间,以及是否需要被多个进程同时看见。只要有一条是“是”,就值得做。两条都是“是”,那就别犹豫了。

2. OpenViking 服务端的落地形态与目录规划

2.1 单进程合体部署还是分体部署

OpenViking 服务端在部署形态上有两种常见做法:一种是把接口层、索引层、存储层全塞在一个进程里跑,另一种是拆开部署,接口和索引在一台机器上,持久化存储单独放。选哪种,取决于你的记忆写入量和查询并发。

我的建议是先合体,后拆分。理由很实在:合体部署的启动成本极低,一条命令就能起来,你在本地就能把整条链路跑通,先验证 Hermes 接进去之后召回是不是正常。等确认逻辑没问题了,再根据压力情况决定拆不拆。上来就搞分体,一旦出问题你根本分不清是接口层配置错了还是存储层没连上,排查成本翻倍。

拆分的最佳时机有两个信号:一是记忆条目数稳定超过一个量级、单次全量重建索引的耗时开始影响查询;二是写入 QPS 持续高位,接口进程被索引构建拖得响应变慢。这两个信号出现之前,合体完全够用。

分体部署时要注意一个细节:接口层和存储层之间的网络延迟会直接体现在每次记忆检索的耗时上,因为一次查询可能要来回好几趟。所以拆的时候尽量放在同一个内网区域,别跨机房,那点延迟在单次请求里看不出来,在每秒几十次检索的链路里就很明显了。

2.2 存储目录、日志目录与快照策略的确定

目录规划这件事看着琐碎,但它是后面所有运维动作的基础。我踩过一次坑:一开始图省事,数据目录用的是相对路径,结果服务是用容器跑的,工作目录一变,服务重启之后直接连到了一个空目录上,毫不知情地跑了两天,所有记忆写进了一个后来被清理掉的临时目录。

正确的做法是所有关键路径都用绝对路径,并且在启动前显式检查目录存在、权限正确。通常需要规划三类目录:

  • 数据目录:向量索引、原始记忆文本、元数据的落盘位置。这个目录必须挂载到持久卷上,容器重建不能丢。
  • 日志目录:访问日志和错误日志分开。访问日志用来算 QPS 和延迟,错误日志用来排障,混在一起查起来很痛苦。
  • 快照目录:定期把数据目录打包出来的备份。快照目录最好和主数据目录不在同一块盘上,否则盘一坏两个一起没。

给一个典型的目录布局,路径名你可以按自己的习惯改,但结构建议保持:

/opt/memory-svc/ ├── data/ # 索引与原始记录,挂持久卷 │ ├── vectors/ │ ├── records/ │ └── meta.db ├── logs/ │ ├── access.log │ └── error.log ├── snapshots/ # 独立磁盘 └── conf/ └── server.yaml

启动前的自检脚本我一般会写成一段幂等的 shell,每次部署都跑一遍,比靠记忆靠谱得多:

#!/usr/bin/env bash set -euo pipefail BASE=/opt/memory-svc for d in data logs snapshots conf; do mkdir -p "${BASE}/${d}" done # 数据目录必须可写,否则服务能起来但写不进记忆 if [ ! -w "${BASE}/data" ]; then echo "data dir not writable" >&2 exit 1 fi # 防止误连到空目录:启动前打印当前条目数,心里有个数 echo "current record count: $(find "${BASE}/data/records" -type f | wc -l)"

最后那行打印条目数看着多余,但它救过我一次——部署脚本跑完发现条目数是 0,立刻意识到挂载卷没挂上,避免了一次静默的数据丢失。

2.3 服务端配置逐项过一遍

配置文件里真正需要你动脑子的其实就那么几项,其余的保持默认就行。下面这份是合体部署时我常用的一份起点配置,字段名按 OpenViking 的常见命名习惯写的,具体版本可能略有差异,以你本地那份示例配置为准:

server: host: 0.0.0.0 port: 8300 workers: 4 storage: data_dir: /opt/memory-svc/data snapshot_dir: /opt/memory-svc/snapshots snapshot_interval_minutes: 60 snapshot_keep: 24 index: metric: cosine # 归一化后的向量优先用这个 dim: 1024 # 必须和 embedding 模型输出维度一致 rebuild_on_startup: false retrieval: default_top_k: 6 min_score: 0.35 max_candidates: 200 limits: max_record_bytes: 16384 write_qps: 200 read_qps: 500 auth: header_name: X-Memory-Key # 实际密钥走环境变量注入,不要写进文件

几个必须重点看的字段,我逐个说下理由。

index.dim是最容易出错的一项,它必须和 Hermes 侧使用的 embedding 模型输出维度完全一致,差一个数就会在写入时直接报维度不匹配。而且这个值一旦定下来,中途改是件麻烦事,已经入库的向量全部作废,只能清库重建。所以第一次启动之前先确认清楚模型维度,别凭感觉填。

retrieval.min_score是召回质量控制的核心。填太高,明明存在相关记忆却检索不出来,表现为“它明明记得却装傻”;填太低,什么边角料都往上下文里塞,表现为“答非所问”。0.35 是个比较中庸的起点,后面第 4 节我会讲怎么调。

limits.write_qps建议一开始就设上,别等被打爆了再加。外部记忆服务最怕的不是读多,而是短时间大量写入把索引重建拖垮,接口响应变慢,进而拖累整个 Agent 链路。设一个低于实际能力的阈值,过载时快速失败,比让请求堆积在队列里强。

auth.header_name这块,密钥绝对不要写进配置文件。用环境变量注入,配置里只写变量名,启动时由进程读取。这一点在容器环境里尤其重要,配置文件经常会被打进镜像或者提交到仓库。

3. Hermes 侧的接入:把读写口子接到远端

3.1 memory provider 的选择与连接参数

Hermes 一般会把记忆后端抽象成一个 provider,切换后端只需要改配置,不用动业务代码。接入 OpenViking 就是选一个支持 HTTP 外部记忆的 provider,然后把服务地址、密钥、超时这几个参数填对。

超时是最容易被忽略的一项。很多示例配置里超时用的是默认值,在本地跑毫无问题,但一旦服务端和 Hermes 不同机,网络稍微抖一下就会超时失败。更麻烦的是失败之后的行为——如果超时被当成“没有记忆”处理,Agent 就会表现成失忆,但它不报错,你根本不知道发生了这种事。

我的做法是把超时拆成两段设:连接超时短一点,表示“服务端到底在不在”;读超时长一点,因为检索本身可能需要几百毫秒。然后显式开启失败降级开关,让请求超时时 Agent 走无记忆路径继续跑,同时打一条告警日志。宁可少召回一条记忆,也不要因为记忆服务抖动导致整个对话卡死。

memory: provider: openviking base_url: http://10.0.0.21:8300 api_key_env: MEMORY_SVC_KEY namespace: default connect_timeout_ms: 300 read_timeout_ms: 1500 on_error: degrade # degrade / fail_fast async_write: true

on_error: degrade这个选项值得强调。它意味着记忆服务挂了,对话还能继续,只是变回"没有长期记忆"的状态。对于面向用户的场景,这个选择几乎总是对的;如果是内部批处理任务、记忆缺失会导致结果错误,才考虑fail_fast。

async_write: true也是我强烈建议开的。同步写入会让每一轮对话都多等一次网络往返,体验上很钝。改成异步之后,写入在后台排队,用户感知不到延迟。代价是进程如果在这几百毫秒内被强杀,最后一条记忆可能没写进去,这个风险可以接受。

3.2 命名空间划分:按会话、按角色还是按工作区

命名空间(namespace)决定了记忆的隔离粒度,这个设计一旦定错,后面迁移数据会很痛苦。常见的三种划分方式各有适用场景,我列个表对比一下。

划分方式隔离效果适用场景主要问题
按会话 ID会话之间完全隔离临时任务、独立咨询跨会话记不住,长期价值低
按用户/角色同一个人跨会话共享长期助手、个人代理多租户要额外做鉴权隔离
按工作区/项目同一项目内共享团队协作、多任务并行需要明确的项目边界定义

实践里最常用的是两级组合:一级按用户(保证不同人之间记忆不串),二级按场景(比如分成"偏好"和"项目上下文"两个空间)。这样查的时候可以只查偏好空间,避免项目细节污染日常对话。

这里有个安全的细节必须提:命名空间不能只靠客户端传参做隔离。如果服务端不校验调用方有没有权限访问这个命名空间,那么只要改一下请求里的 namespace 字段,就能读到别人的记忆。正确做法是把命名空间和 API 密钥绑定,服务端根据密钥反查允许访问的空间列表,客户端传的 namespace 只作为白名单内的选择。这个校验必须在服务端做,客户端做多少层封装都不算数。

3.3 写入时机:轮次结束、任务结束还是异步批量

什么时候写记忆,直接决定了记忆的质量。写太勤,全是碎片;写太少,关键信息漏掉。我试过三种时机,各有优劣。

逐轮写入是最简单的,每轮对话结束就往里塞一条。问题是会产生大量垃圾记录——寒暄、确认、重复的追问全都进去了。检索的时候这些噪声会挤占 top-k 名额,把真正有用的记忆挤出去。

任务结束写入质量最高,因为这时候能拿到完整上下文,可以总结成一条结构化记忆。但它依赖你能明确判断"任务结束",在开放对话里这个边界很模糊。

异步批量写入是我现在的主力方案:对话过程中只把候选记忆暂存,每隔若干轮或者会话空闲时,批量做一次筛选和写入,顺带把重复内容合并掉。好处是写入次数少、单条质量高、噪声少,代价是实现上多一层缓冲逻辑。

不管用哪种时机,有两条规则要守住:一是写入前做一次去重判断,同一条偏好在不同轮次被重复说了五次,不应该存五份;二是给每条记忆带上时间戳和来源会话 ID,前者用于处理"新信息覆盖旧信息",后者用于回溯"这条记忆是哪次对话里来的",排障时非常有用。

4. 召回质量的三次调参:分块、向量、重排

4.1 分块策略对召回的影响比换模型更大

很多人在召回不准的时候第一反应是换 embedding 模型,换个更大的。我的经验是,十次召回问题里大概有七次是分块切得不对,只有两三次跟模型有关。所以调优顺序应该是先看分块,再看阈值,最后才考虑换模型。

分块的核心矛盾是:块太大,一条记忆里混了多个主题,检索到它之后塞进上下文会带来大量无关信息;块太小,语义被切碎,单块表达不完整,相似度计算会失准。

对记忆这种文本,我倾向于按语义单元分块,而不是按固定字数切。一条记忆最好是"一个完整的事实",比如"用户偏好用表格展示对比数据"就是一条,不要把它和"用户所在团队有五个人"揉在一起。按字数硬切最典型的事故是把一句话从中间切开,前半句在块 A,后半句在块 B,两边单独看都语义不明,检索都命中不了。

具体的分块长度,记忆类内容我一般控制在 200 到 500 字之间,超过就说明这条记忆该拆了。另外一定要保留原始记录的完整副本,索引块只是检索用的投影。这样即使索引出问题,你还能从原始记录重建,不至于记忆内容本身丢掉。

4.2 top-k 与相似度阈值的联合调试

top_k和min_score这两个参数必须一起调,单独调任何一个都会出问题。它们的组合关系大致是这样:

  • top_k 小、min_score 高:召回极精准,但经常空手而归。
  • top_k 大、min_score 低:几乎总能召回点什么,但一半是噪声。
  • top_k 适中、min_score 适中:稳定,但需要根据实际数据分布微调。

我的调试方法是拿一批真实问题做小规模回放,观察三个数字:召回条数、第一条的分数、最后一条的分数。理想状态下,相关记忆的分数应该和无关记忆有明显断层。如果分数都挤在一个很窄的区间里(比如全在 0.4 到 0.5 之间),说明要么分块有问题,要么这个 embedding 模型对你的文本类型区分度不够,这时候换模型才有意义。

还有一个实操细节:不同命名空间的阈值应该分开设。偏好类记忆通常短、语义集中,阈值可以高一些;项目上下文类记忆长、信息杂,阈值要低一些,否则容易漏。用一个全局阈值去套所有空间,必然有一边不合适。

4.3 重排与“记忆压缩”补救

当 top_k 拉到 10 以上时,光靠向量相似度排序已经不够了,因为向量相似度衡量的是语义接近程度,不是"这条记忆对当前问题有没有用"。这时候加一层重排会明显改善效果:先向量召回 20 条候选,再用一个轻量的相关性判断把这 20 条重新排序,取前 5 条进上下文。

重排的代价是延迟。加重排之后单次检索可能从几十毫秒涨到两三百毫秒,所以它适合放在对质量要求高、对延迟不那么敏感的场景。如果延迟吃紧,可以只在召回条数超过某个阈值时才触发重排。

另一个常被忽略的手段是记忆压缩。当检索出来的几条内容高度重复时(比如同一个偏好在不同时间被记了三次),可以把它们合并成一条再塞进上下文。这一步能显著降低 token 消耗,也减少了模型被重复信息干扰的概率。压缩可以在写入侧做(去重合并),也可以在读取侧做(召回后合并),我建议两边都做一层,写入侧为主,读取侧兜底。

5. 排障实录:连不上、查不到、写重复

5.1 连接层:超时与鉴权失败的区分方法

接入过程中最先撞上的问题基本都是连不上,但"连不上"其实包含好几种完全不同的故障,处理方式差别很大。我的排查顺序是固定的,从最外层往里剥。

第一步,在 Hermes 所在机器上直接打服务端的健康检查接口。这一步排除的是网络可达性问题。

curl -sv --max-time 3 http://10.0.0.21:8300/healthz

如果这一步就超时,那问题在网络上,跟配置无关。重点看三件事:服务端进程是不是真的在监听(ss -lntp | grep 8300)、监听地址是不是0.0.0.0而不是127.0.0.1、中间有没有防火墙或安全组拦着。我遇到过一次特别隐蔽的情况:服务端配置里写的是127.0.0.1,本地测试一切正常,因为测试脚本就在同一台机器上跑,一换到另一台机器就完全连不上。

第二步,如果健康检查通但业务接口返回 401 或 403,那就是鉴权问题。这里有个坑:很多客户端库在鉴权失败时会重试,重试几次之后报的是超时,你看到的现象是超时,实际原因是密钥错了。所以排查时一定要先看服务端的访问日志,确认请求到底有没有到、返回码是什么,别只看客户端报的错。

第三步,如果连接和鉴权都正常,但响应特别慢,看看是不是触发了限流。限流返回的响应码通常是 429,但有些实现会直接挂起连接,表现为慢而不是报错。查read_qps和实际 QPS 的比值就能确认。

5.2 召回为空:从写入链路反查

"检索不到记忆"这个问题比连不上更让人抓狂,因为一切看起来都正常,接口 200,返回结果是个空数组。我一般按下面的顺序倒着查。

先确认到底写进去了没有。直接调服务端的列举接口,或者干脆去数据目录数文件数,确认记录数在增长。如果记录数一直不变,问题在写入侧,跟召回没关系,别再折腾检索参数了。

写入侧查三件事:async_write开着的时候,进程是不是在记忆刷盘之前就被重启了;写入请求是不是被限流拒绝了(返回 429 但被客户端吞掉);单条记录是不是超过了max_record_bytes被静默截断或丢弃。第三条特别隐蔽,超长记录被拒的时候有些实现只打 debug 日志,默认级别看不到。

确认写进去了,再看命名空间对不对。写入用的 namespace 和查询用的 namespace 不一致,是最常见的原因之一。这个错误在代码里很难看出来,因为两边都是从一个配置项读的,但如果是通过不同途径注入的(一边读环境变量,一边读配置文件),就很容易错开。

最后才怀疑阈值。把min_score临时调到 0 试一次,如果这时候能召回,说明就是阈值太高,结合 4.2 节的方法重新标定。如果调到 0 还是空的,那基本可以确定是维度不匹配或者索引没建起来。

5.3 重复写入与记忆膨胀

跑一段时间之后另一个高频问题是记忆库膨胀:条目数疯涨,但实际有效信息没多少,检索质量越来越差。这个问题不解决,外部记忆的收益会被慢慢吃掉。

根因通常是三个。第一,逐轮写入没有去重,同一句话被不同轮次重复记录。第二,写入失败重试没有幂等键,一次写入超时后客户端重试,服务端其实第一次已经写成功了,结果存了两份。第三,摘要和原文都存了,检索时同时命中,内容重复。

对应的修法:写入时带一个由"内容哈希 + 命名空间"算出来的幂等键,服务端按这个键做覆盖而不是新增;写入前做一次相似度预检,和已有记忆相似度超过阈值的直接合并而不是新增;摘要和原文分开存不同的集合,检索时只查摘要集合,需要展开时再按 ID 取原文。

给一段幂等写入的伪代码示意,重点是那个 key 的算法:

import hashlib def make_idempotent_key(namespace: str, content: str) -> str: # 内容归一化:去空白、转小写,避免格式差异导致同义内容算出不同 key normalized = " ".join(content.strip().lower().split()) raw = f"{namespace}::{normalized}" return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]

这个 key 的稳定性很关键。如果归一化做得不够,比如保留了多余空格或者大小写差异,同一条内容就会算出不同 key,幂等就失效了。我建议归一化规则写死在代码里,不要做成可配置项,避免不同调用方用不同规则。

6. 上线之后:观测、限流与升级

6.1 三个必须盯住的指标

外部记忆服务上线之后,不需要盯着几十个指标,但有三个必须配告警,它们基本能覆盖绝大多数的故障前兆。

第一个是检索延迟的 P95。平均值没有参考价值,因为记忆检索的延迟分布是长尾的,少量慢查询会被平均值掩盖。P95 一旦超过你设定的读超时的六成,就说明快要开始出现超时降级了,该扩容或者查慢查询了。

第二个是写入失败率。异步写入的好处是用户无感,坏处是失败了也没人知道。如果不监控失败率,可能出现连续几天记忆完全没写进去、但对话一切正常的情况,等到用户发现"你怎么什么都不记得了"才暴露。

第三个是条目增长率。正常情况下记忆增长应该是平缓的,如果某天突然陡增,多半是去重逻辑失效或者出现了写入循环。这个指标不需要高频采集,每天看一次趋势就够了。

用表格归纳一下这几个指标的参考阈值:

指标采集方式建议告警阈值异常时的第一反应
检索延迟 P95服务端访问日志聚合> 900ms查慢查询、看索引是否需要重建
写入失败率服务端错误日志计数> 1% 持续 5 分钟查限流配置和存储空间
条目日增长率每日条目数快照差值超过基线 3 倍查去重逻辑和幂等键
召回空结果率客户端埋点> 30%查阈值和命名空间配置

最后一行那个"召回空结果率"其实比延迟还重要,因为空召回不报错、不影响可用性,但会静默地让 Agent 变笨。这个指标只能在客户端埋,服务端看不到。

6.2 限流与降级预案

限流这件事,要在出事之前就配好,不能等被打爆了再临时加。配置思路是分层:在服务端设总闸,在客户端设单实例配额。服务端的总闸保护的是整个服务不被打挂,客户端的配额防止单个 Hermes 实例把额度吃光导致其他实例饿死。

降级预案要提前想清楚,因为真出事的时候没时间现想。我的做法是按严重程度分三档:

  • 轻度:检索延迟升高但还能返回。这时候降低top_k,从 6 降到 3,牺牲一点召回质量换延迟。
  • 中度:检索开始超时。开启degrade模式,跳过记忆检索直接对话,同时打告警。用户会感觉到"它变笨了",但服务不中断。
  • 重度:记忆服务完全不可用。走纯无状态模式,同时把写入请求暂存到本地磁盘队列,等服务恢复后补写。这个本地队列要设上限,防止磁盘被撑满。

第三档那个本地暂存队列,实现上要注意别搞成无限增长。设一个比如 10000 条的上限,超了就丢最旧的,同时打日志。宁可丢记忆,不能撑爆磁盘把主服务带崩。

6.3 升级与快照回滚

外部记忆服务里存的是长期数据,升级的风险比无状态服务高得多。一次不兼容的升级可能让历史记忆全部读不出来,而这个损失是不可逆的——用户不会愿意重新跟你说一遍他半年前交代过的事。

升级流程我固定成这几步,一步都不能省。先手动打一次快照,不要依赖定时快照,因为你不知道上一个快照是什么时候的。然后在影子环境跑一遍新版本,用旧数据的副本启动,验证读取正常。接着停写入不停读取,先把新版本起起来,确认能读到旧数据。最后再切写入。

回滚的触发条件要提前定好,不能凭感觉。我的标准是:升级后半小时内,如果召回空结果率相比基线上升超过一倍,或者检索延迟 P95 翻倍,立刻回滚,不纠结原因。回滚之后再慢慢分析,不要在线上一边排查一边让用户失忆。

快照的保留策略,我建议至少保留 24 份小时级快照加 7 份天级快照。小时级的用于应对"刚刚搞坏了"这种场景,天级的用于应对"坏了一周才发现"这种更麻烦的场景。快照目录记得放独立磁盘,这一点在第 2.2 节提过,但值得再说一遍——主数据盘和快照盘同盘,等于没有快照。

我个人在实际操作中的体会是,这套东西真正花时间的不是第一次搭起来,而是后面几周的调参和观察。第一版配置跑通、能召回、能写入,这只是及格线;真正让 Agent 变聪明的是那些看起来不起眼的细节:分块长度调了两百字、阈值从 0.35 挪到 0.42、给幂等键加了一条归一化规则。每次改动幅度都很小,但累积起来,同一个问题的回答质量差别是肉眼可见的。还有一点,别急着上复杂方案,先把合体部署跑稳,把召回空结果率这个指标盯起来,很多问题在数据里自己就会说话,不需要你去猜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进