数据截至 (上游 commit 358c16bbc6d6)
取用记忆:低延迟 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 反序列化 chats | api_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_companion | AI 陪伴,加了 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.py | get_user_context |
| 编排大脑(切预算/并行/拼装) | src/server/api/memobase_server/controllers/context.py | get_user_context |
| 画像取用 + 拼文本 | src/server/api/memobase_server/controllers/context.py | get_user_profiles_data |
| 事件取用(选搜索/取最近) | src/server/api/memobase_server/controllers/context.py | get_user_event_gists_data |
| 画像读取(Redis 优先) | src/server/api/memobase_server/controllers/profile.py | get_user_profiles |
| 画像截断(prefer/only/limit/token) | src/server/api/memobase_server/controllers/profile.py | truncate_profiles |
| 按对话过滤画像(1 次 LLM) | src/server/api/memobase_server/controllers/post_process/profile.py | filter_profiles_with_chats |
| 事件向量搜索 | src/server/api/memobase_server/controllers/event_gist.py | search_user_event_gists |
| 事件按时间取 | src/server/api/memobase_server/controllers/event_gist.py | get_user_event_gists |
| 上下文模板(中/英) | src/server/api/memobase_server/prompts/chat_context_pack.py | CONTEXT_PROMPT_PACK |
| 读出画像配置(覆盖/追加) | src/server/api/memobase_server/prompts/profile_init_utils.py | read_out_profile_config |
| 默认 taxonomy | src/server/api/memobase_server/prompts/user_profile_topics.py | CANDIDATE_PROFILE_TOPICS |
| taxonomy 类型 | src/server/api/memobase_server/types.py | UserProfileTopic / SubTopic |
| 配置项(覆盖/追加/缓存 TTL) | src/server/api/memobase_server/env.py | Config.overwrite_user_profiles / additional_user_profiles / cache_user_profiles_ttl |
| 行业配置模板 | src/server/api/example_config/profile_for_companion/config.yaml | overwrite_user_profiles |
同组其它章: index.md 全景与阅读地图 · 01-ingestion-buffer.md 数据入口与缓冲 · 02-extract-merge-pipeline.md 抽取-合并管线 · 03-storage-model.md 存储模型。