跳到主要内容

取用记忆:低延迟 context 拼装与画像配置

30 秒导读: 前三章把记忆写进去了(01 缓冲、02 抽取合并、03 落库)。本章讲读回来:一个 GET /context 请求如何在 token 预算内,把用户的画像(profile)和事件时间线(event)拼成一段可直接塞进 system prompt 的文本——以及为什么这一步号称"亚百毫秒"。核心论点只有一句:重活在写入时干完了,取用只是查表 + 截断。


1. 这是什么(零基础也能懂)

一句话定义: Memobase 的"取用"就是一个 HTTP 接口 GET /users/context/{user_id},它返回一段拼好的文本,你把它贴到 LLM 的 system prompt 里,模型就"记得"这个用户了。

解决什么问题: 假设你在做一个聊天机器人。用户上周说过"我对花生过敏、我在东京、我是后端工程师"。这周他再来聊天,你希望模型不必重新问一遍。你需要的是:在每一轮对话开始前,快速拿到一段"这个人是谁 + 最近发生了什么"的摘要,塞进 prompt。

这段摘要必须满足两个硬约束:

  • 快。 它在每次对话的关键路径上,慢一点用户就等一点。
  • 短。 它要占 prompt 的 token 预算,不能无限长,得能按预算裁剪。

用起来什么样: 一次真实调用大致是这样(示意,非源码):

GET /api/v1/users/context/{user_id}?max_token_size=1000&profile_event_ratio=0.6

→ 返回一段文本:
---
# Memory
Unless the user has relevant queries, do not actively mention those memories.
## User Current Profile:
- basic_info::allergy: peanut
- basic_info::location: Tokyo
- work::occupation: backend engineer
## Past Events:
[2026-06-30] User mentioned finishing the migration project.
---

一句话直觉: 把画像当成一张已经填好的表单——写入阶段(02 章的三次 LLM 调用)已经把用户的每条信息填进了固定槽位。取用时不再调用任何 LLM 去"想",只是把表单里相关的行挑出来、拼成文本。这就是"快"的全部秘密。

本节不出现代码细节。记住一件事:取用之所以快,是因为画像是预计算好的。


2. 顶层全景(一次 /context 请求怎么转)

入口在哪: HTTP 层是 api_layer/context.py:get_user_context(第 12-122 行),它只做参数解析,然后调用真正的编排函数 controllers/context.py:get_user_context(第 115 行)。

怎么读下面这张图: 从上到下是一次请求的时间线。注意中间那步——画像和事件是并行拉取的(asyncio.gather),而且两条线都不含"重型 agent 流程"(没有多轮 LLM 推理)。

HTTP 请求 GET /context?max_token_size=1000&...


api_layer/context.py:get_user_context ← 只解析参数
(把 chats_str / topic_limits_json 解成对象)


controllers/context.py:get_user_context ← 编排大脑
│ 按 profile_event_ratio 切分 token 预算
│ 拿 project 的画像配置(决定用哪种语言模板)

├───────────── asyncio.gather 并行 ─────────────┐
▼ ▼
① 画像线 ② 事件线
get_user_profiles_data get_user_event_gists_data
│ Redis/SQL 读全部画像(预计算好的) │ 有 chats → 向量搜索相关事件
│ 可选:filter_profiles_with_chats │ 无 chats → 按时间倒序取最近
│ (1 次 LLM,按当前对话挑相关画像) │
│ truncate_profiles 按 token 预算截断 │
▼ ▼
profile_section (文本) event gists (列表)
│ │
└───────────────────┬───────────────────────────┘

按剩余预算 truncate_event_gists → event_section

CONTEXT_PROMPT_PACK[语言](profile_section, event_section)

最终 context 文本 → 返回

部件一句话职责:

部件干什么文件:符号
HTTP 入口解析 query 参数、JSON 反序列化 chatsapi_layer/context.py:get_user_context
编排大脑切预算、并行拉两条线、拼最终文本controllers/context.py:get_user_context
画像取用读全部画像 + 可选过滤 + 截断controllers/context.py:get_user_profiles_data
画像过滤按当前对话挑相关画像(1 次 LLM)controllers/post_process/profile.py:filter_profiles_with_chats
画像截断按 prefer/only/limit + token 预算裁剪controllers/profile.py:truncate_profiles
事件搜索按对话 embedding 搜事件时间线controllers/event_gist.py:search_user_event_gists
上下文模板把两段套进中/英文模板prompts/chat_context_pack.py:CONTEXT_PROMPT_PACK

3. 核心原理

3.1 预算切分:profile 和 event 分蛋糕

要解决的小问题: 返回的文本总长度受 max_token_size 限制。画像和事件都想占地方,得先说清各占多少。

思路: 用一个比例 profile_event_ratio(默认 0.6)先给画像切一刀:

真实实现(controllers/context.py:get_user_context 第 134-135 行):

assert 0 < profile_event_ratio <= 1
max_profile_token_size = int(max_token_size * profile_event_ratio)

含义:max_token_size=1000、比例 0.6 → 画像最多占 600 token,事件拿剩下的。事件那一侧的预算是减去画像实际用掉的再算(第 192-198 行),所以画像没占满时事件能捡便宜:

if fill_window_with_events:
max_event_token_size = max_token_size - profile_section_tokens
else:
max_event_token_size = min(
max_token_size - profile_section_tokens, # 画像实际用量
max_token_size - max_profile_token_size, # 画像的名义配额
)

关键细节: 默认情况下(fill_window_with_events=False)取两者较小值,意味着即便画像没占满,事件也不会越界去抢画像的名义配额;只有显式开 fill_window_with_events 才让事件填满整个窗口。

3.2 画像取用:为什么这一步几乎不花时间

要解决的小问题: 拿到这个用户的所有画像。

思路——这是"低延迟"论点的核心。 画像不是取用时才生成的,它在写入阶段(02 章)就被抽成了一行行 topic::sub_topic: content 的槽位存好。所以"取用画像" = 一次读,不是"一次计算"。

而且这次读优先走 Redis 缓存。看 controllers/profile.py:get_user_profiles(第 75-80 行):

async def get_user_profiles(user_id, project_id):
async with get_redis_client() as redis_client:
user_profiles = await redis_client.get(f"user_profiles::{project_id}::{user_id}")
if user_profiles:
return Promise.resolve(UserProfilesData.model_validate_json(user_profiles))
# 缓存 miss 才回落到 Postgres 全表查

缓存命中就是一次 Redis GET + JSON 反序列化;缓存 TTL 由 env.py:Config.cache_user_profiles_ttl 定(默认 20 分钟,第 91 行)。这就是 README 主张"亚百毫秒"的代码依据:热路径上没有 LLM、没有向量库,只有一次 KV 读。

拿到全部画像后,get_user_profiles_data(controllers/context.py 第 31 行)做三件事:可选的对话过滤(见 3.3)、truncate_profiles 截断、拼成文本。拼文本的格式(第 73-78 行):

profile_section = "- " + "\n- ".join(
[f"{p.attributes.get('topic')}::{p.attributes.get('sub_topic')}: {p.content}"
for p in use_profiles]
)

3.3 可选过滤:按当前对话挑相关画像(唯一可能引入 LLM 的地方)

要解决的小问题: 用户画像可能有几十条。如果当前话题只跟"工作"有关,把"过敏史""家庭"都塞进去是浪费预算。

思路: 如果调用方传了 chats(最近几句对话),就用一次 LLM 把画像筛一遍,只留跟当前对话相关的。

真实实现在 controllers/post_process/profile.py:filter_profiles_with_chats(第 30 行)。它把画像编号成一个索引表,连同最近对话一起交给 LLM,让模型返回"相关的编号列表"(第 62-89 行):

r = await llm_complete(project_id, input_prompt, system_prompt=system_prompt,
temperature=0.2, model=CONFIG.summary_llm_model, ...)
found_ids = find_list_int_or_none(r.data()) # 解析出编号
ids = [i for i in found_ids if i < len(topics_index)]
profiles = [profiles.profiles[topics_index[i]["index"]] for i in ids]

这是延迟的分水岭。 编排函数第 48-58 行的逻辑决定要不要走这条 LLM 路:

if chats and (not full_profile_and_only_search_event):
p = await filter_profiles_with_chats(...) # 走 LLM 过滤

参数 full_profile_and_only_search_event 的默认值是 True(见 api_layer/context.py 第 82 行),意味着默认不走这条 LLM 过滤——默认行为是"返回全部画像 + 只用 embedding 搜事件"。接口文档自己也标注了代价(api_layer/context.py 第 84-88 行):把它设成 False 会"increase your latency by 2-5 seconds"。

一句话总结这个设计:默认路径把 LLM 挡在门外(快);想要更精准的画像过滤,你显式付出几秒延迟去换。

3.4 事件取用:向量搜时间线,还是取最近

要解决的小问题: "过去发生了什么"这一段该放哪些事件?

思路: 分两种模式,由"有没有对话上下文 + 有没有开 embedding"决定。看 controllers/context.py:get_user_event_gists_data(第 86 行):

if chats and CONFIG.enable_event_embedding:
search_query = pack_latest_chat(chats) # 拼最近 3 句
p = await search_user_event_gists(..., query=search_query, topk=60, ...)
else:
p = await get_user_event_gists(..., topk=60, ...) # 纯按时间倒序

语义搜索这一支在 controllers/event_gist.py:search_user_event_gists(第 65 行):它把对话拿去做 embedding,再用 pgvector 的余弦距离在事件 gist 上找最相似的,并过滤时间窗和相似度阈值(第 99-115 行):

similarity_expr = 1 - UserEventGist.embedding.cosine_distance(query_embedding)
stmt = select(UserEventGist, similarity_expr.label("similarity")).where(
UserEventGist.user_id == user_id,
UserEventGist.created_at > time_cutoff, # 只看 time_range_in_days 内
similarity_expr > similarity_threshold, # 默认 0.2
).order_by(desc("similarity")).limit(topk)

关键细节: 这一支要一次 embedding 调用(而非 LLM 推理),接口文档标注它只加"0.1-1 秒"(api_layer/context.py 第 50 行)——比 3.3 的 LLM 画像过滤便宜一个量级。这解释了默认策略为什么是"画像不过滤、事件用 embedding 搜":用最便宜的检索手段拿到时效性最强的那部分记忆。

事件 gist 的存储与"为什么是 gist 而非完整事件"见 03-storage-model.md

3.5 截断与拼装:套进最终模板

画像截断 controllers/profile.py:truncate_profiles(第 10 行)是取用侧最讲究的一段,它按顺序应用几层规则:

步骤做什么代码行
排序updated_at 倒序(新的优先)第 21 行
prefer_topics指定 topic 提到最前,保证被保留第 22-35 行
only_topics只保留白名单里的 topic第 36-43 行
max_subtopic_size / topic_limits每个 topic 最多留几条子项第 44-58 行
max_token_size累加 token,超预算就砍尾第 62-71 行

其中 topic_limits(每个 topic 单独设上限)会覆盖全局的 max_subtopic_size(第 50-51 行),给调用方细粒度控制。

最终拼装 用中/英双模板 prompts/chat_context_pack.py:CONTEXT_PROMPT_PACK(第 32 行),按 project 配置的语言选一个(controllers/context.py 第 141-142 行)。模板本身很简单(第 6-16 行是英文版):

def en_context_prompt(profile_section, event_section):
return f"""---
# Memory
Unless the user has relevant queries, do not actively mention those memories in the conversation.
## User Current Profile:
{profile_section}

## Past Events:
{event_section}
---
"""

调用方还能传 customize_context_prompt 用自己的模板占位符 {profile_section} / {event_section}(controllers/context.py:customize_context_prompt_func 第 19 行)。


4. 画像是可配置的:topic taxonomy 如何驱动一切

前面反复说"画像是预计算好的槽位"。那这些槽位(哪些 topic、哪些 sub_topic)从哪来?答案是一份可配置的 taxonomy,它既决定取用时你能看到什么,也决定 02 抽取时 LLM 往哪些槽位里填。

4.1 数据结构:UserProfileTopic / SubTopic

taxonomy 的类型定义在 types.py:

  • types.py:UserProfileTopic(第 37 行):一个顶层话题,含 topic 名、可选 description、一组 sub_topics
  • types.py:SubTopic(第 10 行):子话题,含 name / description,以及 update_description(更新策略提示)和 validate_value(是否校验值)。

注意 types.py:attribute_unify(第 6 行)会把所有名字统一成小写下划线形式——这就是为什么取用时看到的是 basic_info::allergy 而非 Basic Info::Allergy

4.2 默认 taxonomy 与三种覆盖方式

默认槽位硬编码在 prompts/user_profile_topics.py:CANDIDATE_PROFILE_TOPICS(第 9 行,如 basic_info 含 Name/Age/Gender…),中文版在 prompts/zh_user_profile_topics.py

怎么改env.py 的两个配置项决定(Config 数据类,第 112-113 行):

配置项效果
overwrite_user_profiles完全替换默认 taxonomy,只用你给的
additional_user_profiles在默认之上追加你的话题

这套"覆盖 or 追加"的逻辑同时出现在两处,一处给全局默认、一处给 per-project 配置:

  • 全局:prompts/profile_init_utils.py:modify_default_user_profile(第 18 行),读 CONFIG.overwrite_user_profiles / CONFIG.additional_user_profiles
  • 按项目:prompts/profile_init_utils.py:read_out_profile_config(第 41 行),读 ProfileConfig.overwrite_user_profiles / additional_user_profiles

两者逻辑一致(第 42-62 行):有 overwrite 就整体替换,否则有 additional 就 default_profiles + 追加,都没有就用默认。

4.3 example_config:现成的行业模板

src/server/api/example_config/ 下放了几套即用配置,展示 taxonomy 如何为不同场景定制:

模板用途文件
profile_for_companionAI 陪伴,加了 companion_preferences/personalization 等话题example_config/profile_for_companion/config.yaml
profile_for_education教育场景画像example_config/profile_for_education/config.yaml
profile_for_assistant助理场景画像example_config/profile_for_assistant/config.yaml
only_strict_profile严格模式(只允许配置里的槽位)example_config/only_strict_profile/config.yaml

以 companion 模板为例,它用 overwrite_user_profiles 定义了 basic_info / companion_preferences / interaction_history / personalization 四个话题(example_config/profile_for_companion/config.yaml 第 5-54 行),整套默认 taxonomy 被替换。

4.4 闭环:taxonomy 同时喂给抽取和取用

这份 taxonomy 是写入侧和读取侧共享的同一张表,这是理解 Memobase 的关键:

env.py 配置 / example_config/*.yaml
(overwrite_ 或 additional_user_profiles)

read_out_profile_config 读出 taxonomy

┌───────────┴────────────┐
▼ ▼
【写入侧·02章】 【读取侧·本章】
pack_current_user_profiles truncate_profiles
告诉抽取 LLM: 取用时按同一套
"只能往这些槽位填" topic/sub_topic 挑选、拼装
│ │
└────► 同一批 topic::sub_topic 槽位 ◄────┘

证据:抽取侧 controllers/modal/chat/utils.py:pack_current_user_profiles(第 22 行)同样调用 read_out_profile_config(第 33-35 行)拿到 project_profiles_slots,并在 strict 模式下据此约束 LLM 只能填 allowed_topic_subtopics(第 36-42 行)。所以你改一次 taxonomy,抽取和取用同时生效——这也是取用能保持简单(只是查表)的前提:槽位在写入时就已规整。


5. 巧妙之处(可借鉴的技术)

  • 把"想"和"取"彻底分开。 昂贵的 LLM 抽取放在写入的异步缓冲阶段(01),取用只做 KV 读 + 截断。在线延迟低不是靠优化查询,而是靠根本不在热路径上计算。证据:get_user_profiles 先查 Redis(controllers/profile.py:76),默认路径不触发 filter_profiles_with_chats

  • 延迟是可选付费项,且明码标价。 接口把三档延迟直接写进参数文档:纯画像(最快)、embedding 搜事件(+0.1-1s,api_layer/context.py:50)、LLM 画像过滤(+2-5s,api_layer/context.py:86)。默认值选在最快档(full_profile_and_only_search_event=True)。

  • 两条检索线并行。 画像和事件用 asyncio.gather 同时拉(controllers/context.py:149),且 return_exceptions=True 让一条线失败不拖垮另一条(第 173-188 行分别处理异常)。

  • taxonomy 单一真源。 同一份 read_out_profile_config 既约束抽取又驱动取用(§4.4),避免"抽的槽位"和"取的槽位"对不上。


6. 边界与局限

  • 画像过滤依赖 LLM 返回结构化编号。 filter_profiles_with_chats 靠正则/解析 LLM 输出的编号列表(post_process/profile.py:77-87),解析失败会直接 reject 而非降级到"返回全部"。但因为默认不走这条路,影响面有限。

  • 事件语义搜索需要 enable_event_embedding 关掉后 search_user_event_gists 直接 reject(event_gist.py:73-82),取用会回落到"按时间倒序取最近"(get_user_event_gists),失去话题相关性。

  • 时间窗是硬边界。 事件只看 time_range_in_days(默认 180 天,api_layer/context.py:60)内的,更久的记忆即便相关也取不到。

  • 画像截断可能丢信息。 超 token 预算时 truncate_profiles 直接砍尾(profile.py:62-71),靠 prefer_topics / updated_at 排序来尽量保住重要项,但没有"压缩"机制。


7. 代码地图(导航索引)

主题文件路径符号名
HTTP 入口(参数解析)src/server/api/memobase_server/api_layer/context.pyget_user_context
编排大脑(切预算/并行/拼装)src/server/api/memobase_server/controllers/context.pyget_user_context
画像取用 + 拼文本src/server/api/memobase_server/controllers/context.pyget_user_profiles_data
事件取用(选搜索/取最近)src/server/api/memobase_server/controllers/context.pyget_user_event_gists_data
画像读取(Redis 优先)src/server/api/memobase_server/controllers/profile.pyget_user_profiles
画像截断(prefer/only/limit/token)src/server/api/memobase_server/controllers/profile.pytruncate_profiles
按对话过滤画像(1 次 LLM)src/server/api/memobase_server/controllers/post_process/profile.pyfilter_profiles_with_chats
事件向量搜索src/server/api/memobase_server/controllers/event_gist.pysearch_user_event_gists
事件按时间取src/server/api/memobase_server/controllers/event_gist.pyget_user_event_gists
上下文模板(中/英)src/server/api/memobase_server/prompts/chat_context_pack.pyCONTEXT_PROMPT_PACK
读出画像配置(覆盖/追加)src/server/api/memobase_server/prompts/profile_init_utils.pyread_out_profile_config
默认 taxonomysrc/server/api/memobase_server/prompts/user_profile_topics.pyCANDIDATE_PROFILE_TOPICS
taxonomy 类型src/server/api/memobase_server/types.pyUserProfileTopic / SubTopic
配置项(覆盖/追加/缓存 TTL)src/server/api/memobase_server/env.pyConfig.overwrite_user_profiles / additional_user_profiles / cache_user_profiles_ttl
行业配置模板src/server/api/example_config/profile_for_companion/config.yamloverwrite_user_profiles

同组其它章: index.md 全景与阅读地图 · 01-ingestion-buffer.md 数据入口与缓冲 · 02-extract-merge-pipeline.md 抽取-合并管线 · 03-storage-model.md 存储模型。