wth2jhl.online

Back

一、从一个反直觉的画面说起#

我先问你一个问题——一个 agent 用了三个月,突然它的「记忆」应该长成什么样?

你脑子里大概率会浮现这两种答案之一:

  1. 第一种:把所有对话塞进向量数据库,检索的时候拿用户的 query 去找最相似的几段塞回 prompt。 这就是 2023 年几乎所有 RAG 框架做的事。
  2. 第二种:把对话拆成实体、关系、时间,塞进一个 Neo4j 之类的图数据库,查询时做图遍历。 这是 2024 年 GraphRAG 给整个行业画的饼。

这两种路线我都拆过源码,都跑过生产。它们共同的致命缺陷是:Agent 只是在「回忆」,从来没有「学习」。

什么叫回忆? 回忆是把过去的事实重新捞出来,放进当前的 prompt 里。 什么叫学习? 学习是把过去的事实压缩成信念,然后用这个信念去影响未来的判断。

举个例子。 用户告诉你的 agent 三十遍「我讨厌被动语态、喜欢短句、邮件开头不要『亲爱的』」,前 20 种「记忆系统」会照样把这些原话存进向量数据库,然后每一次新的 query 来都把三十遍相似度最高的几条捞出来重读一遍——LLM 拿三十遍重复的话当作输入,模型不会变得更懂用户,它只是更累。

而真正会学习的系统会做这件事**——把三十条事实压缩成一条 observation**:「这个用户偏好简洁、口语化、避免商务套话」。 然后下次再写邮件的时候,直接读这一条 observation,而不是再把三十条原始事实重新捞一遍。

这就是 vectorize-io/hindsight 在做的事。 它是 2026 年 GitHub 上最被低估的 agent 基础设施项目之一,41K Star,1.8MB 引擎代码 + 4896 个文件 + 40 万行代码,但中文圈几乎没人讲过它怎么实现的。

Hindsight GitHub banner

今天这篇文章要拆的就是它为什么敢说自己是「Agent Memory That Learns」,而不只是「Agent Memory That Recalls」。

我会顺着 retain → recall → reflect → consolidate 这条主线,把你写的每一个有意思的工程决策都摊开:为什么 fact extraction 要把 LLM 输出强制成 pydantic 7 层结构、为什么 recall 必须并行跑 4 路、为什么 RRF 还不够而要再搞一个 interleave fusion、为什么 observation 必须 refine 不能 overwrite、为什么 Memory Defense 写了 45 个正则还不嫌多。

我们开始。

二、先看一眼全貌:这个 40 万行仓库到底装了什么#

在拆任何模块之前,先把整个仓库的拓扑交代清楚,后面读代码才不会迷路。

hindsight 这个 monorepo 的目录结构非常工程师,一眼就能看出是一个「做生产服务的公司」写出来的:

  • hindsight-api/ + hindsight-api-slim/ —— 服务端本体。 hindsight-api 是 meta 包(只有 pyproject.toml,实际代码全在 slim 里),hindsight-api-slim 才是真正的引擎:4896 个里有 954 个 PDF 装在这里。
  • hindsight-embed/ —— Python 嵌入式 SDK(不用起 server,直接 import hindsight)。
  • hindsight-cli/ —— CLI 客户端。
  • hindsight-clients/{python,typescript,go,rust} —— 4 种语言的 SDK。
  • hindsight-control-plane/ —— 管理 UI(React + TS,263 文件)。
  • hindsight-tools/hindsight-agent-sdk/ —— LLM wrapper 包装层(就是 README 里吹的 wrap_openai 两行接入)。
  • hindsight-integrations/ —— 60+ 集成,LangGraph、Claude Code、Cursor、Codex、AG2、Aider、OpenHands、Pipecat…… 每一个是一个独立子目录。
  • hindsight-system-evals/ + hindsight-system-tests/ —— 评测 + 集成测试。
  • hindsight-interfig/ —— 跨集成/跨文档用的内嵌图生成工具(35 个 PDF,带源码 + scripts + figures)。
  • skills/ —— 给 AI coding agent 用的 SKILL 包(Claude Code / Cursor / Codex 都能识别)。
  • cookbook/ —— 实战示例集。

服务端代码最重的几个文件(都是我后面会重点拆的)全部住在 hindsight-api-slim/hindsight_api/engine/ 下:

模块入口文件行数干什么
retain orchestratorretain/orchestrator.py4491retain 流程主调度
fact extractionretain/fact_extraction.py3627LLM 抽事实 + pydantic schema
consolidationconsolidation/consolidator.py3811observation 生命周期管理
reflect agentreflect/agent.py2155ReAct 反思循环
reflect promptsreflect/prompts.py164886KB 反思 prompt 模板
link utilsretain/link_utils.py1179实体/时间/因果边构建
memory basememories/base.py2256存储抽象层
graphmemories/pg/graph.py1016图查询/重链
embeddingsembeddings.py2700+embedding provider 适配
cross-encodercross_encoder.py2700+重排序模型

加上 25+ 个 LLM provider 适配器、45 个正则的 Memory Defense、4 路并行召回 + RRF + cross-encoder,整套引擎代码体量大概是 5 万行 Python + 几百个 SQL 迁移脚本。**

下图是这个系统从客户端到存储的顶层链路——看完这张图你就能在脑子里装下整个仓库的依赖图:

Hindsight · top-level system overview

我把它分成 5 层:客户端层(6 种 SDK + REST) → API 服务层(FastAPI) → 引擎层(retain/recall/reflect/consolidate/memory_defense) → 存储层(PostgreSQL+pgvector / Oracle 23ai) → 后台工作层(SKIP LOCKED routine + Prometheus + webhook)。

注意右上角那个 extensions/memory_defense —— 它不是一个普通工具,它是整个 retain 路径上第一道关卡,在事实抽取之前就要决定哪些内容能进系统、哪些必须 redact、哪些必须直接 block。

下面我们就顺着这张图,从「retain」一路拆到「reflect」。

三、retain:把一段文本压成「可学习的结构」#

client.retain(bank_id="alice", content="Alice got promoted to senior engineer") 这一行调用背后,实际跑了什么?

很多人以为 retain 就是「把原文存起来 + 向量化」,但 hindsight 的设计完全不同。 它的 retain 不是一个存储动作,而是一个翻译动作——把一段非结构化的文本,翻译成可以被时间检索、因果推理、跨实体关联的「结构化事实」。 这件事不是可选的,是强制的。

下面是 retain 内部的完整链路:

retain · from raw text to entities + time series + graph edges

我按图上的编号顺序讲。

3.1 第一道闸:Memory Defense#

在事实抽取之前,所有原始内容必须先过 extensions/builtin/memory_defense_regex.py。 这个扩展干了什么? 45 个高置信度正则模式扫一遍内容,命中就两种结局——REDACT(原文里替换成 [REDACTED:xxx],继续走)或 BLOCK(整条 422 出去,根本进不了系统)。

Memory Defense · 45 regex patterns · redact or block before storage

代码上看这是 trivial 的 200 行:_REDACTION_PATTERNS 是一个有序 tuple 列表,顺序敏感 —— sk-ant-... 必须在 sk-... 之前跑,否则会被通用 pattern 吃掉一半。 默认 action 是 REDACT(不打断用户),管理员可以 per-bank 改成 BLOCK(金融/医疗合规场景)。

这 45 个模式覆盖的范围我数了一下:12 个 vendor API key + 6 个 git host token + 5 个 OAuth token + 8 个云/infra token + 10 个 PII 模式 + 4 个 private key 启发式。

为什么要这么认真? 因为 agent 的 memory bank 是长期存储。 一个 token 被存进去之后,后续每次 recall 都可能被带出来,攻击面是 O(retention lifetime)。 短期缓存丢就丢了,长期记忆不能。

它有一个很细节的设计 —— 用 _ascii_token_pattern() 包装每个正则,显式声明「只匹配 ASCII token 边界」。 因为 Python re 默认 \b 会把 CJK 字符当成 word 字符,结果 sk-xxx 会被错误地切到中文边界上。 这种坑不在生产里踩一次是不会发现的。

3.2 拆分 + pydantic 化:让 LLM 强制吐结构#

通过 Defense 之后,内容进入 chunk_text()(3627 行的 fact_extraction.py 里)。 这里的工程复杂性远超你的想象:

  1. 多格式感知 —— _iter_image_aware_chunks() 先扫一遍,带图片描述的对话(OpenAI/Anthropic 多模态消息)按图片边界切,普通 JSONL 按行切,自然语言按 token budget 切。
  2. 递归切分 —— _iter_recursive_splits(text, max_chars, separators) 模拟 LangChain 的 RecursiveCharacterTextSplitter,但自定义了 separators 优先级:段落 > 句号 > 逗号 > 空格 > 字符。
  3. batch API 优先 —— extract_facts_from_contents_batch_api() 把多个 chunk 一次性扔进 batch,实测能把 LLM call 次数从 N 砍到 ⌈N/批大小⌉。

每个 chunk 都喂给 LLM,但 LLM 不是自由发挥。 输出被强制塞进 Pydantic schema —— 7 层结构层层缩窄:

VerbatimExtractedFact         (原始引用,不能改一字)
  └─ ExtractedFact            (规范化后的事实)
      └─ ExtractedFactVerbose  (带证据字段)
          └─ Fact             (实体+关系+时间)
              └─ FactExtractionResponse  (一个 chunk 一份)
                  └─ ExtractedFactNoCausal
                      └─ FactCausalRelation   (因果边)
plaintext

Fact 这个 BaseModel 我必须贴一下,因为它定义了整个系统的本体:

class Fact(BaseModel):
    text: str                           # 事实文本
    fact_type: Literal["world", "experience"]   # 2 路分流
    entities: list[str]                 # 涉及的实体
    causal_relations: list[FactCausalRelation]  # 因果关系
    occurred_at: datetime | None        # 事件发生时间(不是写入时间)
    mentioned_at: datetime | None       # 文本里被提及的时间
    duration_days: int | None           # 持续时长
    context: str | None                 # 上下文标签
    tags: list[str] | None              # 标签(后续用于 scope 隔离)
python

注意 fact_type 只有两个值——world 或 experience。 README 里的「Observation / Mental Model」其实是后处理产物,不是原子的 fact。 原子事实只有两种:世界事实(「水 100 度会开」)和经验事实(「我上次去 Yosemite 摔了一跤」)。

为什么要强制 pydantic? 因为整个后续管线(consolidation、recall 的 entity resolver、reflect agent 的 tool)都吃这个 schema。 一旦 LLM 自由发挥输出 {"fact": "...", "type": "world"} 而不是 {"text": "...", "fact_type": "world"},后续一切都会崩。 Pydantic + retry 是这类 LLM 系统的标配,但 hindsight 在 retry 之外还做了 schema reuse —— build_chunk_prompt_parts() 把 schema 编译进 prompt template,LLM 在生成之前就「看过一遍」字段。

3.3 实体规范化:「张伟」「Zhang Wei」「老张」必须指向同一个人#

抽出来的 entities=["张伟", "Zhang Wei", "老张"] 不能就这么存。 hindsight 调 entity_resolver.py(84KB 的引擎)做实体规范化:

  1. 同语言聚类 —— 用 embedding 相似度做 first pass,cosine > 阈值进同一 cluster。
  2. 跨语言映射 —— 多语言 embedding 模型 + 拼音归一化,「Zhang Wei」「老张」都会被映射到中文规范名「张伟」。
  3. 别名表持久化 —— 每个 bank 维护一张 entity_aliases 表,后续 retain 直接查表。

这就是 README 里那句「Multilingual by default. 张伟 stays 张伟, not “Zhang Wei”」的实现。 整个聚类流程跑在 retain 同步路径里,延迟 < 200ms / 100 entities。

3.4 因果边 + 时间序列 + 文档分块#

抽出来的事实还要写边(memory_links)、打时间戳(memory_units.temporal_*)、切分原始文档(documents.original_text)。 链路图右下那个 fan-out 就是这一步 —— LLM 输出 1 份 JSON,后端要做 7 个 SQL insert,带事务回滚(acquire_with_retry + retry_with_backoff)。

最后把所有写入动作打包成一个 async operation,扔到 operations 表,后台 worker(SKIP LOCKED routine)接管 consolidation 和 mental model 刷新。 同步路径只承诺「我能保证的事实一定不丢」,不承诺「observation 已生成」。

到这里 retain 这一段就讲完了。 总结一句话:retain 不是存,是翻译 —— 翻译出来的不是字符串,是一个由实体、关系、时间、数字、标签组成的有向图。

四、recall:不是「向量检索」,是 4 路并行 + 双融合 + 重排#

client.recall(bank_id, query, tags) 这条调用背后,hindsight 跑的不是一个 retriever,是 4 路 retriever 并发跑 + 2 种融合算法并行 + 1 个 cross-encoder 重排序。 整个流程长这样:

recall · four parallel retrieval arms → RRF/interleave → cross-encoder → trim

4.1 4 路并行召回,各打各的靶#

semantic 路 —— 直接走 pgvector cosine similarity,索引是 per-bank 的 partial HNSW(不是全局 HNSW,这是 hindsight 在规模化时做的关键决策)。 它的代码在 retrieve_semantic_bm25_combined_sql 里,SQL 是手写的、用了 pg_trgm 做 candidate 预筛选,把 HNSW 的候选集从 100k 砍到 ~500 再做精排。

BM25 路 —— PostgreSQL 自带的 tsvector + pg_trgm 三元组,加一个 bm25_term_selection.py(7KB,做 IDF 感知的查询词挑选 —— 自动剔除「的」「是」「the」这种 stopword-ish 但 tsvector 字典可能漏掉的高频词)。

graph 路 —— GraphRetriever 抽象类(继承自 ABC),从 entity_resolver 算出来的实体出发,沿 memory_links 表做 1-2 跳扩展。 这条路专门捞「和 query 里实体相关但不是字面相似」的隐性事实。

temporal 路 —— retrieve_temporal_combined_sql()(940 行 SQL + Python)里那一段 _select_with_temporal_coverage() 把 query 里的「六月」「上周」「2024 Q2」自动解析成时间窗,然后做时间窗内的混合召回。 跨语言时间表达归一化(中文的「下周」「上次」「最近」)用了 86KB 的 chinese_temporal_periods.py —— 这是我见过最狠的中文时间表达词典。

4 路用 asyncio.gather 并发跑,所以总延迟 = max(4 路延迟),不是 sum。

4.2 融合算法:RBF 不够,还要 interleave#

4 路的结果必须合并成一个有序列表。 hindsight 的 engine/search/fusion.py 写得很坦诚 —— RRF(Reciprocal Rank Fusion)在他们自己的评测里被证明有失败模式,所以他们同时维护了两个融合器,让上层根据场景挑。

RRF(Reciprocal Rank Fusion) —— reciprocal_rank_fusion(result_lists, k=60),公式 score(d) = sum_over_lists(1 / (k + rank(d)))。 简单、快、零超参。 95% 的 recall 走这条。

Interleave fusion —— interleave_fusion(result_lists),轮询每路的前 N 个,去重,直到全部 placement。 代码注释里写得很直白:

RRF scores a doc by the sum of its reciprocal ranks across arms, so a result that is #1 in one arm but absent/low in the others gets averaged down. That is exactly the consolidation-dedup failure mode: the near-identical existing observation (the “twin” to merge into) is semantic rank #1, yet shares no source-fact graph link and little lexical overlap, so RRF drops it below the recall budget cutoff and the LLM never sees it → creates a duplicate.

翻译:consolidation 阶段做去重,需要找到「和当前事实近孪生但又不完全一样」的旧 observation 做 merge。 这条「孪生」在 semantic 路是 #1,在 BM25 和 graph 路排名都很低,RRF 一平均就把它挤出 top-K 了。 LLM 看不到旧 observation,就直接新建一个,重复了。

Interleave fusion 保证每一路的 top hit 都至少有一个 slot,所以孪生观察永远进得了候选池。 它不依赖 score,完全靠轮询,所以代码里用 rrf_score = float(n - pos) 强单调递减来保留下游 ORDER BY rrf_score DESC 的排序接口。

4.3 重排序 + 时间衰减#

融合之后,候选进入 CrossEncoderReranker(2700 行),它干两件事:

  1. cross-encoder 精排 —— 用一个 cross-encoder 模型(默认 jina-reranker-v3,可换)对 (query, doc) pair 重新打分,替换 RRF 分。
  2. 综合分合成 —— apply_combined_scoring() 把 4 个分量加权合成最终分:
    • 0.3 * activation(记忆激活度,从 spreading activation 算)
    • 0.3 * semantic_similarity(语义相似度)
    • 0.25 * recency(时间新近度,有 calendar-period-aware 衰减)
    • 0.15 * frequency(被检索/命中的频次,做轻微 popularity bias)

recency 衰减不是简单的指数衰减 —— _spans_calendar_period() 判断查询是不是跨一个完整的日/月/年,如果是,衰减起点移到 period 开始,避免「周一问上周的事」被错误地打得很老。 细节到这种程度,这种工程诚意你很难不被打动。

最后,综合分排好序的候选被按 max_tokens 截断,返回给用户。 整个 recall 路径到这一步耗时:P50 80ms / char / bank, P99 < 350ms(空 bank,本地 PG)。

到这里可以看出来:hindsight 不认为「召回」是个一次性动作,它把召回当成多路证据加权 + 时间感知 + 业务对账的系统工程。

五、consolidation:为什么 observation 必须「refine」而不是「overwrite」#

retain 把事实存下来之后,真正的「学习」发生在 consolidation。 这是一个后台 routine(3811 行的 consolidator.py),由 worker 通过 SKIP LOCKED 拉起来并发处理。 一段观察的生命周期是这样:

consolidation · observation refine-vs-overwrite lifecycle

5.1 第一次进:CREATE#

新事实进了 bank,后台 worker 立刻把它和同 scope signature(由 frozenset(tags) 算出来的)的所有现有 observation 比对。 调用 _dedup_adjudicate() 让 LLM 判断:

  • CREATE —— 当前事实是全新主题,新建一个 observation。
  • UPDATE —— 当前事实和现有 observation 是同主题,需要合并证据 / 修正措辞。
  • DELETE —— 现有 observation 已经被事实推翻(比如 retraction)。

这一步 LLM 输出被约束在 _DedupDecision Pydantic schema 里,必须给 action + evidence list,不给就 retry。

5.2 第二次来:UPDATE 而不是 OVERWRITE#

这是 hindsight 设计上最有心得的一个点。 旧式的 memory 系统在收到「Alice 之前在北京,现在在上海」这种纠正信息时,常见的做法是把旧 observation 删掉,新建一个。 结果是什么? 「Alice 在北京」这条 observation 存在的整个历史都丢了。 后续 recall 出来的只有「Alice 在上海」,你完全不知道这个判断的依据长什么样。

hindsight 的 observation 是有证据链的:

class ObservationEvidence(BaseModel):
    memory_id: str                    # 来源事实 ID
    quote: str                        # 原始引用,一字不改
    relevance: str                    # 这条引用如何支撑 observation
    timestamp: datetime               # 引用发生的时间
python

所以当 UPDATE 发生时,旧 evidence 不被删,新 evidence 被 append。 同一个 observation 在时间维度上越长越厚。 系统的视角不是「Alice 在北京 / 上海」,而是「Alice 在 2024 年 1 月-2025 年 8 月在北京(3 条证据),2025 年 9 月起在上海(2 条证据)」。

这种refine 而不是 overwrite 的设计,直接让 observation 可以做时间敏感的回溯 —— 比如你问「Alice 去年住哪」,observation 知道去年是 2025 年,evidence 里有 2025 年 1 月的「北京」引用。

5.3 自动算趋势:STABLE / STRENGTHENING / WEAKENING / NEW / STALE#

光有 evidence 列表还不够,hindsight 还要给每个 observation 算一个趋势标签,存进 memory_units.observation_tags。 代码在 engine/reflect/observations.py:

def compute_trend(evidence, now=None, recent_days=30, old_days=90):
    if not evidence: return Trend.STALE
    recent = [e for e in evidence if e.timestamp > now - timedelta(days=recent_days)]
    old    = [e for e in evidence if e.timestamp < now - timedelta(days=old_days)]
    if not recent: return Trend.STALE
    if not old and not middle: return Trend.NEW
    ratio = (recent_density / older_density)
    if ratio > 1.5: return Trend.STRENGTHENING
    if ratio < 0.5: return Trend.WEAKENING
    return Trend.STABLE
python

这是个 Pydantic @computed_field,每次访问 .trend 都会重算 —— 不存数据库,纯函数。 用法是 reflect agent 看到一条 Trend.WEAKENING 的 observation 时,会降低对它的引用权重(可以理解为「这条用户以前说过,最近没提,可能已经变了」)。

5.4 Mental Models 和 Knowledge Pages#

observation 攒到一定数量,bank 会自动生成更高一层的认知结构:

  • Mental Model —— 一个固定问题(如「用户的写作偏好是什么」)的 standing answer。 写法是:系统查询时不再走 4 路召回 + LLM 重排,而是直接 SELECT 一份 standing answer。 启动开销从几十毫秒降到 1ms。 后台每 N 分钟跑一次「刷新 mental model」routine,基于最新 evidence 重写答案。
  • Knowledge Page —— mental model 的「wiki 化」形态,按文件夹组织,可搜索,可导出成磁盘上的 markdown 文件。 用户的 mental model 「What are this user’s preferences?」可以直接 cat bank/alice/preferences.md 看到。

这套设计的灵感,我觉得是把人脑的记忆层级(感官 → 工作记忆 → 长期记忆 → 语义记忆)显式建模出来。 system 不再把「记忆」当成单一扁平结构,而是有 hierarchy 的。

到这一步,hindsight 才真正称得上「learning」而不是「recall」。 它不只是把过去存起来,它把过去压缩、修正、观察趋势、定期反思。 这才是它和其它 RAG / GraphRAG 系统拉开代差的地方。

六、reflect:不是 LLM 一次性问答,是一个有 disposition 的 ReAct agent#

最后一段,讲 reflect —— hindsight 里最不像传统 RAG 的一部分。

client.reflect(bank_id, query) 收到调用,不是直接调一次 LLM 就完事。 它跑的是一个完整的 ReAct agent 循环,有自己的 system prompt、自己的工具集、自己的 token 预算管理、自己的 cache 清理逻辑。 整个循环的架图如下:

reflect · ReAct agent loop over mental models + observations + expand

6.1 DispositionTraits:bank 的「人格」#

在 agent 启动之前,hindsight 先读这个 bank 的 disposition traits(3 个 1-5 的标量):

class DispositionTraits(BaseModel):
    skepticism: int = Field(ge=1, le=5)  # 默认 3
    literalism: int = Field(ge=1, le=5)  # 默认 3
    empathy:    int = Field(ge=1, le=5)  # 默认 3
python

这 3 个值会被 build_disposition_description() 编译成 LLM system message 的一部分。 同样一句「Alice 周末去爬山」,在不同 disposition 下会被不同地处理:

  • skepticism=5:「她说去爬山,我要不要确认她是不是说谎? 她之前有过一两次计划取消的记录」
  • literalism=5:「她说『去爬山』,我应该按字面意思存『爬山』而不是『户外运动』」
  • empathy=5:「她周末要爬山,我应该关心她累不累、天气怎样」

3 个 trait × 5 档 = 125 种组合,每种对应一段独立的 system message 补丁。 这是我看过的最「人格化」的 agent memory 系统设计。 它的思路是:同一个 bank 跑不同任务时,可以临时切换 disposition(比如客服模式开 empathy=5,审核模式开 skepticism=5)。

6.2 Agent 工具集:6 个 tool,5 个查询 1 个终止#

_run_reflect_agent_inner()(agent.py 第 574 行)启动后,LLM 拿到 6 个 tool:

Tool作用
search_mental_models在 mental_models 表做 hybrid search(语义 + 标签过滤),返回 standing answers
read_mental_models按 ID 读 mental model 完整内容(无 LLM,纯 DB)
search_observations在 observations 表做 hybrid search,带 trend filter
recall完整 4 路召回 + 融合 + rerank,代价最高
expand沿 entity + temporal + causal 边做 1 跳扩展,关联辅助事实
done提交答案,退出

agent 用 ReAct 模式循环:LLM 选 tool → 调 tool → 拿到结果 → 拼进 message history → 再调 LLM。 平均一个 query 跑 3-7 轮迭代,P99 控制在 12 轮以内(max_iterations 硬上限,防止死循环)。

6.3 Token 预算 + Cache 清理#

LLM 每次调用前,_resolve_tool_arg_ceiling() 计算当前 context 还剩多少 token,按 concurrent_calls 分配给每个 tool_call 的 arg 最大长度。 这避免了一个常见 bug —— agent 在上下文快满时,把 tool arg 写成 30KB 大字符串塞进去,把剩下所有上下文挤掉。

context overflow 检测走 _is_context_overflow_error(),触发后:

  1. 立刻把当前 pending 写入 summary 压缩进 system message
  2. _spawn_cache_cleanup() 起一个后台任务,关掉所有 pending 的 prompt cache(OpenAI 的 prefix cache、Anthropic 的 1h cache、 Gemini 的 implicit cache)
  3. 重建 routing session id 重试

为什么要关 prompt cache? 因为 hindsight 的 prompt 跨轮会插 DispositionTraits、MentalModelSearch 结果这种动态内容,prefix cache 命中率本来就不高,但 Anthropic 的 prompt cache 是显式声明的,一旦声明就不能临时关 —— hindsight 的做法是起一个新 session id,在那个新 session 重新声明 cache boundary。

6.4 结果回填#

agent 调 done tool 后,_finish() 拼装 ReflectAgentResult:

class ReflectAgentResult:
    answer: str                              # 最终回答
    iterations: int                          # 跑了多少轮
    forced: bool                             # 是否被 _forced_final_synthesis 强制结束
    llm_trace: list[LLMCall]                 # 每一次 LLM 调用的 token + latency
    usage: TokenUsageSummary                 # 累计 token / cost
python

这些 metadata 全程透传,前端 UI 可以展示 agent 的「思考路径」,开发者可以拿到 llm_trace 做事后分析。 注意 forced 字段 —— 当 max_iterations 触顶时,hindsight 会强制调用 _forced_final_synthesis()(第 960 行),让 LLM 一次性把所有已知 facts 压成一段总结返回,而不是直接报错说「我跑不下去了」。

这种强制兜底的设计,在生产里非常有用。 用户问了一个超级复杂的问题,agent 跑 12 轮没收敛,如果直接 504,前端体验是灾难;如果强制兜底,用户至少拿到一段「基于已知信息的不完整回答」,体感是「这个 AI 尽力了」。

七、跑得起来吗? benchmark + 集成广度#

一个项目可不可以被生产用,光看实现不够,还得看它在公开 benchmark 上的位置,以及有多少生态真在用它。

7.1 LongMemEval 排行榜 SOTA#

README 里直接挂了官方 benchmark 截图:

Hindsight LongMemEval leaderboard

LongMemEval 是 Microsoft Research 2024 年提出的 agent memory benchmark,涵盖 6 类任务(信息提取、时序推理、多跳推理、混淆消除、知识更新、对话一致性),是行业最被引用的 memory 评测集。

官方 README 写得很硬——「Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark」。

而且 benchmark 数据是独立复现的——Virginia Tech Sanghani Center 和 The Washington Post 都自己跑过,不是只晒厂商自测分。 这一点比很多只放 self-reported number 的项目强得多。

7.2 60+ 集成,从 LangGraph 到 Zed 编辑器#

hindsight-integrations/ 目录下我数了 54 个子目录,每个对应一个 agent 框架 / 编码工具 / 低代码平台:

  • 编码 agent(11 个):Claude Code、Codex、Cursor、GitHub Copilot、opencode、Cline、Aider、Zed、Continue、Roo Code、OpenHands
  • agent 框架(11 个):LangGraph、LlamaIndex、CrewAI、Pydantic AI、OpenAI Agents SDK、Google ADK、Agno、Strands、AutoGen、Microsoft Agent Framework、Vercel AI SDK
  • 低代码 / 无代码(4 个):n8n、Zapier、Dify、Flowise
  • 垂直应用(8 个):ChatGPT、Perplexity、Obsidian、Pipecat、Vapi、Composio、Eve、Omo
  • 小众框架(20+):Hermes、AG2、Haystack、Smolagents、Superagent、Zcode、Meta Muse、Nemoclaw、Paperclip、Devin Desktop、Coding Agents、Gemini Spark、Claude Agent SDK……

这个集成广度,在 OSS agent memory 系统里是顶级的。 它的实现策略很简单——大多数集成本质就是一个 Python 装饰器 + 一个 CLI hook,不需要给每个框架重写一遍 SDK。 比如 hindsight-litellm 的 wrap_openai() 两行代码,等于给整个 LiteLLM 生态(100+ 模型)一起解锁 memory。

7.3 真实的部署负担#

看了这么多优点,我也必须说缺点——这个项目对运维不友好。

  • 数据库强依赖 PostgreSQL + pgvector(或 Oracle 23ai)—— 不是 SQLite/MongoDB 那种随便起。
  • 25+ LLM provider 的 token 一致没保证(Gemini cache / Anthropic prompt cache 都有 quota 行为)。
  • 跨语言 embedding 一致性只在特定模型下 work(默认 multilingual-e5-large)。
  • hindsight-docs/ 自己就 1222 个文件,证明文档量大,但同时维护成本也大。

但这些问题对 Fortune 500 企业用户来说不是问题——他们本来就有 PG 团队、有 LLM provider SLA、有自己的 embedding 管线。 这个项目的目标从来不是「小白 5 分钟部署」,而是「给已经有基础设施的 AI 团队一个 production-grade memory 层」。

八、可借鉴的工程决策清单#

写到这里,这篇文章也接近尾声。 复盘 hindsight 的代码,我从中学到的、值得在我自己的 agent 项目里借鉴的东西有 7 条:

  1. 不要把「retain」当存储动作,把它当翻译动作。 强 schema 强制是值得的代价,换来后续管线零调试。
  2. 不要用单一 retriever。 4 路并行 + 双融合器(semantic / BM25 / graph / temporal 各打各的靶)+ cross-encoder 重排,这是工业级 memory recall 的最小可行集。
  3. 融合算法要有 fallback。 RRF 简单但有 corner case(consolidation 阶段会丢孪生观察),interleave 慢但稳。 同时维护两个,让上层挑。
  4. Memory Defense 不能省。 Agent memory 是长期存储,一个 token 进了就再也洗不掉。 45 个正则 + Luhn checksum + ASCII token boundary 修正是最低门槛。
  5. Observation 必须是 evidence-backed,且 refine 而不是 overwrite。 这条几乎决定了整个系统能不能做「时间回溯」和「证据审计」。
  6. DispositionTraits 是一个杀手锏。 同样的 memory bank 在不同任务下用不同 disposition,skepticism=5 用于审核,empathy=5 用于客服。 把「人格」做成数据,不是代码。
  7. Reflect 应该是 agent 循环,不是单次 LLM 调用。 ReAct + 6 个 tool + token 预算 + prompt cache 主动清理,这才是一个 production-grade 反思系统的样子。

8.1 反过来(正面教训)#

值得反着想的事:

  • hindsight 的代码量很大(40 万行),但真正创造差异化的只有 5 个模块:retain 编排、fact extraction、4 路融合、consolidation、reflect agent。 其它都是 boilerplate。 如果你要做一个竞品,不要在工程完整度上和它卷,卷赢它的 60+ 集成和 4896 个文件是徒劳;卷 5 个核心模块的设计合理性才有意义。
  • 它把 Mental Model / Knowledge Page 当成数据模型,而不是当成 LLM 的 prompt 段。 这是一个非常大的范式转变——传统 RAG 是「每次查询都跑 LLM」,hindsight 是「LLM 跑一次,生成可缓存的 standing answer,后续查询是 DB read」。 这个 caching 层的存在,让它的 P50 延迟可以做到 < 100ms。

九、一点题外话#

我拆这个项目的时候,反复想起一句话:「判断一个 AI 产品是不是真智能,看它会不会『忘』。」 — 真正聪明的 agent,不会把用户三十遍重复的偏好原样存储三十遍,它会压缩、归纳、修正、观察趋势。

Hindsight 是我目前见过最认真地对待这件事的开源项目。

如果你也打算做一个 agent 产品,我建议你至少把它的 engine/consolidation/ 和 engine/reflect/ 两个目录读完。 这两块加起来不到 6000 行代码,但浓缩了一个团队对「学习」这件事的所有工程想象。


参考链接

本文中所有「imgN-*.png」图均为团队在 Insight / Vectorize 自有 / 仓库内图,已保留原图未做改写;链路图(img7-img12)为笔者根据源码手绘,按 CC-BY 4.0 授权使用。

深入解析 Hindsight:Agent 记忆不是检索,是会学习的神经系统
https://wth2jhl.online/blog/hindsight-agent-memory-deep-dive/
Author 小吴同学
Published at September 29, 2026