
项目标题里这个组合我最初看到时的第一反应是又有人要把记忆从进程里搬出去了。hermes 这类智能体运行时天然带着一个“短期记忆”的壳子对话上下文、工具调用记录、任务中间态全塞在里面跑单轮 Demo 的时候很舒服一旦上服务端、多实例部署、长周期任务问题就成片地冒出来。openviking 在这里扮演的角色就是那台专门负责“记住东西”的外部记忆服务端——它不参与推理不关心你用哪家模型只负责把记忆稳定地存下来、按语义取回来、按策略过期掉。这套 hermes 外部记忆配置 openviking 服务端的落地过程我前后折腾了两轮第一轮踩的是协议和维度不一致的坑第二轮踩的是容量和降级。这篇就把整条链路从设计取舍到配置细节到排障实录讲透适合已经跑通过本地 Demo、准备把智能体记忆搬到独立服务端的同学也适合正在评估“要不要单独搞一套记忆服务”的团队做参考。文中涉及的组件名沿用项目代号具体实现按常见工程实践补全。1. 把记忆从进程里搬出去整体架构与取舍1.1 进程内记忆撑不住的三个临界点刚开始写 hermes 的时候记忆是放在内存字典里的键是会话 ID值是消息列表加一份简单的摘要。这套东西在单机、单会话、几十分钟的交互里毫无问题甚至比接外部服务更快因为它没有任何网络往返。但工程上总会撞到三个临界点撞到任何一个你就得考虑外置。第一个临界点是重启即失忆。hermes 作为服务端跑起来之后滚动发布、扩缩容、机器迁移都是常态进程一换内存里的历史全没了。用户昨天跟它聊过的偏好、上周确认过的方案全部归零。有人会说那就落盘成 JSON我试过单个会话几百条消息还能接受量大之后每次加载都要全量反序列化启动时间从两秒涨到四十多秒而且并发读写要自己加锁一加锁吞吐就掉。第二个临界点是多实例不一致。服务端至少要跑两个副本做高可用用户的请求被负载均衡打到 A 实例下一轮打到 B 实例B 根本不知道 A 记了什么。做会话粘滞能缓解但粘滞一旦遇到实例漂移就断而且粘滞本身会把负载均衡变成“看起来均衡”。真正干净的做法是让记忆变成一个所有实例都能访问的共享状态也就是把状态从计算节点里剥离出去。第三个临界点是容量与检索质量的双重天花板。进程内记忆做检索无非是全量遍历加字符串匹配几万条以内还行几十万条就开始卡。而记忆这件事的价值恰恰在“多”和“久”上——只有积累得足够多语义召回才有意义。你想上向量检索就得引入索引结构索引结构又要占内存、要构建、要持久化这些活儿塞在推理进程里等于让一个本该专注推理的服务兼职做数据库职责完全错位。想清楚这三个点架构方向就定了hermes 保留“记忆策略”openviking 承担“记忆存储与检索”。前者决定记什么、什么时候记、怎么用后者保证存得住、查得快、过期得掉。这个切分听起来很教科书但真正落地时边界画错的地方比比皆是下面细说。1.2 hermes 与 openviking 的职责切分我见过最常见的错误切分是把“摘要生成”放在服务端。理由是服务端有存储生成完直接存进去省一次往返。这个设计短期内很省事长期会变成灾难摘要策略是要随业务频繁调整的比如早期按轮次摘要后来改成按主题摘要每次调整都要发服务端版本而服务端通常比业务侧发布周期长得多。更要命的是摘要需要调用模型服务端一调模型就把一个纯存储组件的资源模型彻底搞乱了——你原本按 IO 和内存规划容量现在还得按推理算力规划。我最终的切分是这样的hermes 负责一切需要“理解”的动作openviking 负责一切只需要“存取”的动作。具体落到职责上hermes 做分块、做去重判断、做摘要与要点抽取、做向量化调用、做召回后的重排和 token 预算裁剪openviking 做向量与原文的持久化、做近似最近邻检索、做元数据过滤、做 TTL 过期与冷热分层、做多租户隔离。中间通过一组语义明确的 HTTP 接口通信接口只传“已经处理好的东西”不传原始对话流。这个边界有个很实际的判断标准如果某个逻辑改了之后需要重新处理历史数据它就该放在 hermes 侧。因为 hermes 可以写迁移脚本、可以灰度重刷而服务端一旦要做数据迁移就是一次带停机的重活儿。反过来如果某个逻辑改了只需要改配置、不需要动历史数据比如索引参数、过期天数那就放服务端改完重启或者热加载即可。还有一点容易被忽略记忆的“写入时机”必须由 hermes 控制不能由服务端推断。有些实现会让服务端监听消息流自己决定什么时候落库听起来很自动实际会写出大量垃圾记忆——工具调用的中间结果、失败重试的重复请求、模型的自我纠错话术全被当成有效记忆存进去几轮之后召回结果全是噪音。写入时机是业务语义只有 hermes 知道这一轮到底有没有产生“值得记住”的东西。1.3 三种落地形态的对比与选择真正开始配 openviking 的时候摆在面前的有三种形态我挨个试过最后选了第三种。形态部署方式优点明显短板适用场景嵌入式作为库链接进 hermes 进程零网络开销配置最简单无法多实例共享容量受进程内存限制崩溃丢数据风险高单机 Demo、离线批处理单机独立服务独立进程本地磁盘持久化部署简单调试直观延迟可控单点故障扩容只能垂直升配中小规模、内部工具、验证阶段主从集群多副本 分片外部对象存储做冷备高可用、可水平扩展、能做读写分离运维复杂度上一个台阶需要处理索引重建与一致性对外服务、多租户、长期记忆第一轮我图快用的是嵌入式跑了两周就撑不住了——两个副本之间的记忆完全是两份用户体验像精神分裂。第二轮换成单机独立服务配合会话粘滞顶了一阵问题在于数据量涨到三百万条向量之后单机内存吃紧一次索引重建让服务停了将近二十分钟那段时间所有请求都走了降级路径召回直接退化成关键词匹配体验断崖式下跌。第三轮才上主从读写分离写走主节点读走从节点从节点延迟容忍度放到秒级因为记忆读取本身对“刚写进去的东西必须立刻读到”的要求没那么高除非是同一轮对话内的即时引用那种情况在 hermes 侧做一层本地短缓存就够了。选形态的时候有个很实用的经验先按“记忆条数增长速度”倒推而不是按“当前条数”。当时我们的量是每天新增大概八万条记忆片段单条平均 1.5KB含原文和元数据一天就是 120MB 原始数据向量另算。按三个月保留期估原始数据 11GB 左右向量按下面 2.1 的算法大概 27GB加起来 38GB再乘 1.3 的冗余系数接近 50GB 常驻内存。这个数字一出来单机形态直接出局因为一台机器扛 50GB 索引内存还要留出查询缓存和系统开销太勉强了。2. 服务端上线前的准备环境、协议与容量测算2.1 部署形态与硬件资源估算容量测算这件事很多人是拍脑袋定的结果要么买多了浪费要么上线两周就告警。我把当时的算法完整写一遍你换成自己的数字直接套。向量部分的内存占用核心公式是单条向量字节数 维度 × 每维字节数。用的是 1536 维、float32 精度那就是 1536 × 4 6144 字节约 6KB。这是裸向量实际还要算索引结构的开销。以常见的图索引为例每个节点要维护邻居列表邻居数取 16 的话邻居 ID 按 4 字节整型算正反两个方向大概是 16 × 2 × 4 128 字节再加一些层级和标记位实践中按裸向量的 1.5 倍估比较稳。所以单条向量常驻内存约 9KB。原文和元数据这块一条记忆片段平均 800 字中文UTF-8 编码约 2.4KB元数据时间戳、租户、会话 ID、来源标签、重要度分数约 200 字节合计约 2.6KB。如果原文不放内存只放磁盘这部分内存开销可以省掉但检索后回表会有一次磁盘随机读P99 会抖。我们的做法是原文放磁盘、只把摘要平均 120 字约 360 字节放内存热数据整体控制在 1KB 以内。代入三百万条向量 3,000,000 × 9KB ≈ 27GB摘要 3,000,000 × 1KB ≈ 3GB合计 30GB。查询时的临时内存候选集、重排缓冲按并发的 20% 预留约 6GB。系统和其他进程留 4GB。总计 40GB配 64GB 内存的机器留出增长空间。QPS 和延迟预算是另一半。hermes 在生成回答前要注入记忆这段耗时会直接进用户可感知的等待时间我给自己定的总预算是 300ms网络往返 20ms、openviking 检索 80ms、回表取原文 60ms、hermes 侧重排与裁剪 100ms、留 40ms 抖动余量。80ms 的检索预算意味着 top-k 不能开太大我们最终定的是先召回 40 条候选、过滤后重排取 8 条注入。这个数字是调出来的k 从 20 加到 40召回质量明显提升从 40 加到 80质量几乎不动但延迟涨了将近一倍。这就是典型的收益拐点值得花半天时间自己测一遍。磁盘这块主节点要放索引快照、预写日志和原文。快照大小约等于向量内存的 0.8 倍做了量化压缩三百万条约 22GB预写日志按峰值写入量估每天 120MB 数据、放大 3 倍保留 7 天约 2.5GB原文 8GB。加起来 35GB 左右配 200GB 的盘很宽松主要是给索引重建时的双份快照留空间——重建期间新旧快照并存这一条很多人漏算结果重建到 90% 报磁盘满前功尽弃。2.2 接口契约把记忆操作收敛成四个动作接口设计上我走过弯路。最早按 CRUD 设计六个接口写着写着发现调用方要自己拼过滤条件、自己处理版本冲突、自己判断该不该覆盖hermes 侧的代码越写越厚。后来重新收敛只留四个动作抽象层级拉高一点反而好用了。动作路径语义关键参数幂等性写入POST /v1/memory/write批量写入记忆片段namespace、items[]、dedup_key是按 dedup_key召回POST /v1/memory/query语义检索 元数据过滤namespace、vector、top_k、filters、min_score是遗忘POST /v1/memory/forget按条件软删除或降权namespace、ids 或 filters、mode是健康GET /v1/memory/health就绪与负载探测无是四个动作里写入必须幂等这条是硬要求。hermes 侧的写入是异步的网络超时、上游重试、消息重复投递都会导致同一批记忆被写多次。如果服务端不做去重你会看到召回结果里同一条记忆出现三四遍浪费 token 还干扰模型判断。我们的做法是让 hermes 生成 dedup_key规则是“会话 ID 片段序号 内容哈希前 16 位”服务端对 dedup_key 建唯一约束重复写入直接返回已存在不报错。召回接口要带 min_score这个参数看着不起眼但没有它你会被低质量结果淹没。向量检索永远会返回 top_k 条哪怕整个库里没有一条相关它也会把最不相关的那几条捞出来。hermes 侧拿到这些噪音要么全注入污染上下文要么自己再过滤一遍。把阈值下沉到服务端一次过滤掉省网络省算力。阈值定多少要看你的向量模型我们的经验是同一模型下取 0.62 到 0.68 之间低于 0.6 噪音明显高于 0.75 会漏掉一些表述差异大的正确记忆。这个值必须用真实数据测不能照抄别人的。遗忘接口要区分软删和降权。有些记忆不该立刻删比如用户说“我最近在学吉他”三个月后这条过期了但“学过吉他”这个事实可能还有用。我们设计了两种模式hard 直接标记删除查询时过滤掉decay 把重要度分数乘以一个衰减系数让它排序靠后但还留着。实践中大部分场景用 decay只有涉及明确撤回的场景用户说“忘掉刚才说的”才用 hard。2.3 鉴权、命名空间与网络边界命名空间的设计我建议一开始就做成三级租户 / 智能体 / 会话。哪怕你现在只有一个租户也先把字段留出来。原因是记忆这东西的隔离需求来得特别突然——某天产品说要做多智能体协作A 智能体的记忆不能被 B 看到你如果没有租户和智能体两级就得改表结构、改索引、迁移数据那是一次彻头彻尾的重构。三级命名空间的落地方式是在每条记忆上带三个字段查询时强制带上完整的命名空间前缀服务端在索引层按前缀做分区。这里有个性能细节如果你的向量库不支持带过滤条件的检索只能先全量检索再过滤那过滤会变得非常昂贵尤其是租户多、单租户数据少的情况等于每次都在全库搜索。选型时一定要确认支持“过滤 检索”联合执行或者至少支持分区索引。我们是按租户分区、租户内按智能体再分会话 ID 只做元数据字段不做分区因为会话数量太多分区粒度太细反而拖慢。鉴权这块服务端不要直接暴露在业务网。常见做法是 hermes 侧持有一个服务令牌通过内网调用服务端只做令牌校验和限流不做复杂的用户体系。真正的用户身份由 hermes 在命名空间里体现服务端不需要知道“这是哪个用户”它只需要知道“这个令牌能访问哪些命名空间”。这种设计下服务端的安全边界清晰出了问题也容易定位是哪一层。限流要按命名空间做不能只按全局做。我遇到过一次某个高频调用的智能体把全局配额吃满导致其他所有智能体的记忆写入全部超时业务侧看起来像是“记忆功能整体挂了”。改成每个命名空间独立令牌桶之后单个智能体再怎么疯跑也只影响自己。还有一条服务端的写入接口要做背压。当队列积压超过阈值时直接返回 429 而不是无限排队。因为记忆写入的时效性是有窗口的积压十分钟之后写进去也没什么意义了不如让 hermes 侧感知到压力主动降采样——比如只写重要度高的片段丢掉低价值的中间态。3. 配置实操从零跑通写入与召回3.1 openviking 服务端配置逐项拆解配置文件我按功能块拆开讲每一项都说明为什么这么配你按自己的量调整数字即可。下面是主节点的配置骨架server: listen: 0.0.0.0:8848 max_connections: 512 read_timeout_ms: 5000 write_timeout_ms: 10000 graceful_shutdown_ms: 30000 auth: mode: token token_header: X-Memory-Token tokens_file: /etc/openviking/tokens.yaml namespace_scope: true storage: data_dir: /data/openviking wal_dir: /data/openviking/wal wal_sync: every_1000ms snapshot_dir: /data/openviking/snapshots snapshot_interval_min: 30 snapshot_keep: 3 index: type: hnsw dimension: 1536 metric: cosine m: 16 ef_construction: 200 ef_search: 64 max_elements: 5000000 query: default_top_k: 40 max_top_k: 200 min_score: 0.62 filter_mode: pre_filter timeout_ms: 800 lifecycle: default_ttl_days: 180 decay_half_life_days: 45 compact_cron: 0 3 * * * cold_archive_after_days: 120几个关键项展开说。max_connections配 512是因为 hermes 侧用的是连接池每个实例 8 个连接、按 60 个实例估480 个留一点余量。这个数配小了会出现“连接被拒”配大了浪费文件描述符按“实例数 × 池大小 × 1.1”估比较稳妥。wal_sync: every_1000ms是个权衡项。设成每次写都同步写入延迟会到毫秒级甚至十毫秒级吞吐掉一个数量级设成每秒同步掉电最多丢一秒的数据。记忆这个场景丢一秒的数据是可以接受的hermes 侧有重试和幂等键所以选了每秒。如果你们是金融、医疗这类不能丢的场景改成always但要把写入做成异步批量别让同步落盘阻塞请求线程。m和ef_construction影响索引质量和构建速度。m16是通用起点维度高、数据分布复杂可以调到 32代价是内存涨、构建慢。ef_construction200意味着构建时每个节点探索 200 个候选质量好但构建慢三百万条数据大概要跑四十分钟。ef_search64是查询时的探索宽度这个值直接决定召回率和延迟调到 32 延迟降三成但召回率掉 5 个点调到 128 召回率涨 2 个点延迟翻倍。我建议先用 64压测之后看召回指标再微调。filter_mode: pre_filter这个配置非常重要。它的意思是先按元数据过滤再检索而不是检索完再过滤。前面提过后者的性能在租户多的时候会崩掉。但注意pre_filter 对索引的要求更高如果过滤器选择性很强比如只命中几百条性能反而不如暴力扫描。我们的经验是选择性超过 1% 用 pre_filter低于 1% 走暴力扫描分支这个判断服务端可以自己算。decay_half_life_days: 45是重要度衰减的半衰期。意思是 45 天之后一条从没被召回过的记忆重要度分数降到原来的一半。这个参数决定了记忆的“新鲜度偏好”设太短老记忆很快沉底设太长新记忆上不来。45 天是我们看着会话内容分布调的你可以先从 30 天开始试。3.2 hermes 侧客户端配置与字段映射服务端配好了hermes 侧要接上。下面是我用的客户端配置和初始化代码字段映射的部分最容易出错逐行说明。# hermes/memory/remote_client.py import hashlib import time import httpx class RemoteMemoryClient: def __init__(self, cfg): self.base_url cfg[endpoint] self.namespace ( cfg[tenant_id], cfg[agent_id], cfg[session_id] ) self.client httpx.Client( base_urlself.base_url, timeouthttpx.Timeout(connect1.0, read2.0, write3.0, pool1.0), limitshttpx.Limits(max_connections8, max_keepalive_connections4), headers{X-Memory-Token: cfg[token]}, ) def _dedup_key(self, session_id, seq, text): digest hashlib.sha256(text.encode(utf-8)).hexdigest()[:16] return f{session_id}:{seq}:{digest}超时设置要分三段不能只设一个总超时。连接超时 1 秒因为服务端在同一个内网超过 1 秒基本就是有问题了读超时 2 秒对应服务端查询 800ms 的预算再加传输抖动写超时 3 秒因为批量写入比查询慢。如果只设一个总超时常见的情况是连接阶段卡住把额度全吃光真正的读请求反而没时间了。连接池max_connections8是配合服务端 512 上限算的。这里有个坑max_keepalive_connections如果配成和 max 一样长时间空闲后连接会被服务端超时关掉客户端还以为是活的下一次请求直接失败。配成一半让多余的连接自然老化反而更稳。命名空间做成三元组传到服务端但服务端存的时候是拼成一个字符串前缀。注意分隔符不要用冒号因为会话 ID 里可能带冒号有些框架用session:uuid的格式拼起来之后前缀匹配会错位。我们用/分隔并且在服务端做了一层校验不允许各段里出现/。写入调用长这样def write(self, items): payload { namespace: self.namespace, items: [ { dedup_key: self._dedup_key( self.namespace[2], it[seq], it[text] ), content: it[text], summary: it[summary], vector: it[vector], metadata: { seq: it[seq], role: it[role], ts: it[ts_ms], importance: it[importance], source: it[source], }, } for it in items ], } resp self.client.post(/v1/memory/write, jsonpayload) resp.raise_for_status() return resp.json()ts用毫秒时间戳整数不用字符串。这条很关键我们第一版用的 ISO 格式字符串跨时区部署之后排序全乱而且服务端做时间范围过滤时字符串比较的性能比整型差不少。统一成 UTC 毫秒整数展示层再格式化。importance是 hermes 侧算出来的重要度0 到 1 之间。算法很简单但有效用户明确的偏好陈述、确认过的结论、纠正类信息给高分0.8 以上寒暄、重复确认、工具调用的中间结果给低分0.3 以下。这个分数参与召回的最终排序能在向量相似度接近时把真正重要的那条顶上来。3.3 写入链路分块、去重、向量化、落库写入链路我拆成四步每一步都有明确的失败处理。分块。不要按固定字数硬切。按语义边界切优先在段落、句子结束符处断开单块目标长度 200 到 500 字。太短了语义不完整召回来是半句话太长了向量被稀释一条记忆里混了好几个主题相似度反而下降。我们的做法是先按段落切超长段落再按句号切最后把不足 100 字的碎片合并到前一块。去重。这里有两层。第一层是精确去重靠 dedup_key服务端做。第二层是语义去重在 hermes 侧做写入前先拿这批内容去召回一次如果某条新记忆和已有记忆的相似度超过 0.95就不写了改成“更新已有记忆的时间戳和重要度”。这一步很有必要否则同一个事实被反复表述库里会堆几十条近似记忆召回时全是重复内容。阈值 0.95 是比较保守的低于 0.9 会误杀一些表述相近但细节不同的记忆。向量化。注意批量别一条一条调。向量化接口通常支持批量一次 16 到 32 条吞吐能提升好几倍。还要做失败隔离一批里有一条超长被拒不要让整批失败逐条重试或者直接跳过并记录。落库。异步写走本地队列加后台线程。队列要设上限满了就丢低重要度的保住高价值记忆。落库失败要重试重试三次还失败就写本地落盘文件等下次启动补传。这个兜底很土但管用我们靠它捞回过一批因为服务端滚动发布而丢掉的记忆。def ingest(self, segments): # 1. 语义去重 survivors [] for seg in segments: hits self.query_by_vector(seg[vector], top_k1, min_score0.95) if hits: self.touch(hits[0][id], importanceseg[importance]) else: survivors.append(seg) # 2. 批量向量化每批 24 条 for i in range(0, len(survivors), 24): batch survivors[i:i 24] vectors self.embed_batch([b[text] for b in batch]) for b, v in zip(batch, vectors): b[vector] v # 3. 异步落库 self.write_queue.put_many(survivors, blockFalse)这段代码有个细节值得说语义去重那一步是逐条查的会带来额外的召回开销。如果写入量大可以把这一步改成批量查一次把多条向量打包发过去服务端按 batch 处理。我们没这么做因为写入链路的 QPS 远低于读取多数时候是每轮对话结束才触发一次压力不大。但如果你的场景是海量日志灌入这一步必须批量化否则写入会被去重拖死。3.4 读取链路召回、重排、上下文裁剪读取链路的每一步都在跟 token 预算赛跑。整个预算就那么多注入的记忆多了留给用户输入和模型思考的空间就少了。召回。用当前用户输入不是整段对话历史去向量化然后查 top 40带上 min_score 0.62 和命名空间过滤。为什么用当前输入而不是整段历史因为整段历史会把多个主题混在一起向量被平均掉召回来的东西四平八稳但不对题。如果当前输入太短比如就一句“嗯”就把最近三轮的用户消息拼起来做查询向量这时候意图在上下文里。重排。40 条候选不能直接注入要重排。我们的重排分数是三部分加权向量相似度占 0.6重要度占 0.25时间新鲜度占 0.15。新鲜度用指数衰减算半衰期 30 天。这三项加起来排序取前 12 条进入下一步。这套权重是拍出来的但拍得比较有依据向量相似度是主体重要度用来压住那些“相似但没用”的寒暄新鲜度用来在新旧两条相似记忆里选新的。还有一条硬规则要加进重排同一主题的记忆最多保留 3 条。有时候用户连续聊了半小时同一个话题库里存了二十条相关记忆向量相似度都很高全排上来会把上下文挤爆而且信息高度冗余。做法是重排后按主题标签做分组每组截断到 3 条。主题标签也是 hermes 侧在写入时打的用简单的关键词聚类就能凑合。上下文裁剪。这是最后一步也是最容易出错的一步。裁剪的目标不是“塞进多少条”而是“塞进多少 token”。我们的做法是给这段注入设一个 token 上限比如 1200 token然后按重排顺序往里塞塞到超了就停并且优先保证每条记忆的摘要完整——宁可少塞一条也不要塞半条。def build_context(self, query_text): vec self.embed(query_text) hits self.client.post(/v1/memory/query, json{ namespace: self.namespace, vector: vec, top_k: 40, min_score: 0.62, filters: {status: active}, }).json()[items] for h in hits: h[score] ( 0.60 * h[similarity] 0.25 * h[metadata][importance] 0.15 * self._freshness(h[metadata][ts]) ) hits.sort(keylambda x: x[score], reverseTrue) picked, used, topic_count [], 0, {} for h in hits: topic h[metadata].get(topic, misc) if topic_count.get(topic, 0) 3: continue cost self._token_cost(h[summary]) 8 if used cost 1200: break picked.append(h) used cost topic_count[topic] topic_count.get(topic, 0) 1 return \n.join(f- {h[summary]} for h in picked)注意最后输出的是摘要而不是原文。原文平均 2.4KB摘要 360 字节差了将近七倍。有人担心摘要丢信息实践中确实会丢但丢的那部分大多是修饰语关键事实都在。如果某条记忆确实需要原文细节可以在摘要后附一个引用 ID让模型按需通过工具取回这就是所谓的“分层记忆”实现复杂度高一档但对长记忆场景值得。4. 稳定性与性能调优压测、缓存与降级4.1 该盯哪些指标怎么压记忆服务的监控指标和普通业务服务不太一样它没有“成交”“转化”这种业务指标全是基础设施指标。我列了自己实际在盯的一组指标采集方式告警阈值说明写入 P99 延迟服务端埋点 800ms超过说明 WAL 或索引写入阻塞召回 P99 延迟服务端埋点 400ms超过说明 ef_search 过大或候选集膨胀召回空结果率业务侧统计 25%超过说明阈值太高或写入链路断了向量维度不一致次数服务端校验 0任何一次都说明客户端版本错配索引内存占用进程指标 配额 85%留出重建空间写入队列积压服务端队列长度 5000触发背压开始返回 429去重命中率服务端统计突增 50%说明上游有重复投递查消息队列压测的方法上我用的是“重放 合成”混合。重放是拿线上真实的召回请求日志按原始并发模式打回去用来验证延迟分布合成是造一批极端的请求比如超大 top_k、超宽时间范围、空过滤条件用来找边界问题。只做重放会发现不了慢查询只做合成又脱离真实分布。压测时有个容易被忽略的点要压写入和查询混合的场景。单独压查询很漂亮P99 才 60ms一上混合写入查询延迟立马飙到 300ms原因是索引在动态更新的时候会加读写锁。测出来这个数之后我们才决定把写入改成批量、按 5 秒一批刷进去把锁竞争降下来。4.2 批量写入与多级缓存批量写入的收益比想象中大。单条写入的瓶颈不在 IO 而在索引更新每写一条就要更新一次图结构三百万条的库每次更新都要走一遍邻居选择。改成 500 条一批索引更新次数降两个数量级写入吞吐涨了将近八倍。代价是延迟一条记忆从产生到可被召回最多要等 5 秒。这 5 秒延迟在多数场景没问题但有一种场景会翻车同一轮对话内的即时引用。用户说“就按刚才那个方案来”这个“刚才”是 2 秒前说的还没落库召不回来。解决办法是在 hermes 侧加一层本地短缓存存最近 20 轮对话的原始消息召回时先查本地缓存命中就直接用没命中再走服务端。这样既保证了即时性又不牺牲写入吞吐。缓存分三级进程内 LRU存最近召回的原文TTL 60 秒、本地短缓存存最近对话轮次TTL 30 分钟、服务端侧的查询结果缓存对相同查询向量做 10 秒缓存。第一级解决“同一会话反复召回同一批”的问题第二级解决“刚说完就引用”的问题第三级解决“多个实例同时处理相同查询”的问题。三级加起来实测召回 P99 从 380ms 降到 110ms。第三级缓存要小心它只对“完全相同的查询向量”有效而查询向量几乎不可能完全相同用户的话每次都不一样。所以它的实际命中率很低主要作用是在重试场景下挡住重复请求。我后来把它改成“按查询文本哈希缓存”命中率才上去一些。4.3 三级降级策略服务端不可能永远健康降级路径必须提前设计好而且要在故障发生前就演练过。第一级超时降级。召回超时默认 800ms直接返回空结果hermes 侧按“无记忆”继续生成。这条路径必须做到“零异常、无感”用户只会觉得回答没那么贴切不会看到报错。实现上要注意超时之后的后台请求要取消不能让它继续占资源。第二级读主库降级。从节点不可用或者延迟过大时读请求切到主节点。主节点本来就承担写入加读之后压力会上去所以这时候要主动把 top_k 从 40 降到 20把省下的算力用来扛 QPS。这个降级是自动的靠健康检查触发不需要人工介入。第三级降级到关键词检索。这一级是兜底向量服务整个不可用时才会走。做法是服务端保留一份倒排索引只对摘要建用查询文本做关键词匹配。质量肯定差很多但至少能把“用户之前提过自己的名字”这类硬事实召回来不至于完全失忆。这份倒排索引的内存开销不大三百万条摘要大概 1.5GB我觉得这个代价换一个兜底能力很值。降级开关要能手工强制也要能自动恢复。手工强制的场景是你发现召回质量整体下降了可能是向量模型换了版本想暂时关掉记忆注入排查就强制切到“无记忆”模式。自动恢复要加防抖健康检查连续成功 10 次才恢复否则会在故障边缘反复横跳。5. 常见问题与排查实录5.1 高频故障速查表这套系统上线后我记录了一份问题台账挑出复现率最高的几个整理成表遇到类似症状可以先对号入座症状最可能的原因快速定位手段处置方式召回结果全是无关内容查询向量用了整段历史或维度错配打印查询向量前 8 位和库内向量对比改用当前输入做查询校验维度配置同一条记忆重复出现幂等键设计不当或没启用查 dedup_key 重复率补齐幂等约束跑一次历史去重写入成功但召回不到批量写入未刷盘或索引未更新查队列积压和索引版本号等待刷盘检查批次大小服务重启后记忆大量丢失WAL 同步间隔过大且未优雅关闭对比重启前后条数缩短同步间隔开启优雅关闭查询延迟突然翻倍索引内存溢出触发换页查进程 RSS 和 swap扩容或降 ef_search某租户记忆串到别的租户命名空间拼接分隔符冲突抽样检查前缀换分隔符加字段级校验时间范围过滤结果异常时间戳时区不一致抽样对比 ts 与展示时间统一 UTC 毫秒整数索引重建期间服务不可用重建占用全部资源查重建时的 CPU 和锁等待限速重建走影子索引切换表格里“索引重建期间服务不可用”这一条我踩得最狠。第一次重建没做限速全速跑CPU 打满查询延迟从 80ms 飙到 4 秒前端直接超时。后来改成影子索引新建一个索引按 20% 的速率后台灌数据灌完之后原子切换别名旧索引延迟几分钟再回收。整个过程服务不中断代价是需要双份内存这就是前面容量测算里为什么要留 1.3 倍冗余的原因。5.2 几个文档里不会写的坑坑一向量维度改了但配置没改全。换了个向量模型维度从 1536 变成 1024只在客户端改了服务端索引配置还是 1536。表现是写入报错但错误信息很含糊说是“维度不匹配”你以为是数据问题。实际是配置问题。我的做法是在服务端配置里加一道启动校验用一个固定测试向量跑一次写入和召回跑不通报错退出别让它带病启动。坑二TTL 过期和软删除打架。我们同时有 TTL 自动过期和手动的 soft delete 标记结果出现了一批“已删除但还能召回”的记忆。原因是召回过滤只查了 status 字段没查 expire_at而 TTL 清理任务又是每天凌晨跑一次中间这段时间就漏了。修法很简单召回过滤条件里同时带上时间判断或者在写入时就计算好有效状态。这类问题本质是“状态有多个来源”设计时要收敛成单一状态机。坑三摘要里带了不该带的东西。摘要生成时把用户的原始指令也编进去了比如“请帮我查一下XX然后总结”摘要变成“用户要求查XX并总结”。这条记忆被召回之后注入上下文模型会误以为这是一个新指令重复执行。教训是摘要在 hermes 侧生成时要过滤掉指令性表述只保留事实陈述。这个过滤规则很土就是用一组模式匹配把“请”“帮我”“麻烦”开头的句子剔掉但有效。坑四连接池的僵尸连接。服务端滚动发布时旧连接被服务端关掉客户端连接池里还留着下一次请求拿到的就是死连接报“连接被重置”。加了重试之后看似解决了但重试会把延迟推高。根治办法是客户端定期探活每 30 秒发一次健康检查发现死连接就剔除。还有一个更简单的办法把连接的最大存活时间设成小于服务端的空闲超时让客户端主动换连接。坑五重要度分数通货膨胀。一开始 importance 只有 0.3 和 0.8 两档用着还行。后来有人加了几档又有人按“模型觉得重要”来打分结果分数分布越来越偏大部分记忆都跑到 0.7 以上排序失去区分度。教训是重要度必须是规则算出来的不能让模型自由发挥而且要定期看分布直方图中位数一旦漂到 0.6 以上就该重新校准了。坑六冷热分层没做全量常驻内存。早期图省事所有记忆都在内存索引里包括两年前的。后来发现 80% 的召回只命中最近 30 天的数据老数据基本是死重。做了冷热分层之后120 天以上的记忆挪到磁盘索引内存占用降了四成召回延迟只涨了 15ms。如果你们的记忆量在千万级这一步是必须的别指望靠加内存硬扛。最后分享一个我一直在用的自检习惯每次改完记忆相关的配置不管改的是服务端还是客户端都跑一遍固定的五个用例——写入一条新记忆、按原文召回、按同义表述召回、按时间过滤召回、删除后再召回。这五个用例覆盖了主链路跑完只要两分钟但能挡掉九成的配置失误。踩过几次坑之后我越发觉得记忆系统最怕的不是性能不够而是“悄悄地错了”——它还在返回结果只是返回的结果不对这种错误靠监控指标发现不了只能靠固定的回归用例兜住。