腾讯把 ima 的 Copilot 架构文章发出来那天,我翻了三遍。ima 这个产品大家不陌生,个人知识库加对话助手的定位,任何时候问它“我三个月前收藏的文章里关于某件事的说法”,它都能把出处翻出来,这种体验背后的架构设计,比产品形态本身更值得抄。问题在于,官方那套架构是为多端、多云、多租户设计的,我手头只想要一个跑在自己笔记本上的本地版 ima.copilot。于是我用了一个周末,把知识引擎、意图路由、工具编排、记忆管理这几层用 FastAPI 自己搓了一遍,能导入本地文档、能基于知识库问答、能调用工具完成任务。这篇文章不聊官方架构的表面,只讲我拆解它之后落地的取舍,以及踩过的坑。
1. 从官方架构里抽出来的三层骨架,我做了这些本地简化
1.1 官方架构文章里藏着的几条主线
当时那篇文章给我的整体感觉是:它不是一个"功能需求说明书",而是一张围绕"知识工作流"的组织图。拆开来看,核心链路大致是四条:
- 接入与会话层:多端入口,负责把不同渠道来的用户请求统一成一条对话流,同时维护每个会话的上下文状态。
- 知识引擎层:文档进来后经过解析、切分、向量化、索引流程进入知识库;查询时做语义检索和相关性排序,把命中的片段作为上下文喂给模型。
- 模型路由层:不同任务走不同模型,比如闲聊走快模型、复杂推理走强模型、生成任务走带流式能力的模型,对外暴露统一接口。
- 编排与记忆层:负责判断用户意图、编排工具调用,并把短期会话记忆和长期用户画像区分开。
这四条线在官方架构里是解耦的,模块之间通过接口通信。对于本地版,我不可能也不需要照搬整套分布式设计,但有一个原则我坚持保留了:每层职责单一,层与层之间只通过数据结构通信。这个原则帮我省掉了后来绝大多数调试成本。
那么问题来了,本地版该保留什么、裁剪什么?我当时的判断其实很直白:单机环境下,凡是"为了支持多个用户/多台机器"引入的组件,全部砍掉;凡是"为了让问答更准"的组件,全部保留。于是有了下面这张取舍表。
1.2 本地化取舍:哪些保留,哪些直接砍
| 官方模块 | 官方形态 | 本地版方案 | 取舍理由 |
|---|---|---|---|
| 接入层 | 多端网关、统一鉴权、限流 | FastAPI + 局域网访问 | 单用户不需要网关,鉴权用一段简单 Token 代替 |
| 会话状态 | 分布式会话存储 | SQLite + 内存 Session | 本地的状态量小到没有必要引入 Redis |
| 文档解析 | 流水线式解析服务 | Python 解析器(PDF/Markdown/HTML) | 个人知识库的文件类型有限,不需要做成服务 |
| 向量库 | 大规模分布式向量集群 | Qdrant 单机 Docker | 万级分块量级下单机内存检索足够快 |
| 模型路由 | 统一模型网关 | OpenAI 兼容 Router | 保留多模型切换能力,但不引入独立网关服务 |
| 缓存 | 多级分布式缓存 | 进程内字典 + SQLite 落盘 | 省掉独立缓存服务,启动更快 |
这张表里最反直觉的一点是:缓存我反而砍了。官方加多级缓存是为了扛并发和降低模型调用成本,本地版只有一个用户在跑,缓存带来的收益远不如它引入的一致性复杂度高。我最后只做了两层:进程内内存缓存最近检索结果,SQLite 里存文档索引元数据。内存不够就直接淘汰,不需要额外维护。
裁剪完之后,本地版的骨架变成了四个进程级模块:FastAPI 服务、知识引擎、编排循环、模型路由。接下来逐个说,最重的是知识引擎这部分。
2. 知识库这层决定成败:切分、向量化、混合检索和重排
2.1 分块策略:别用固定窗口,要按文档结构切
所有做过知识库问答的人应该都有体会:分块是链路里最不起眼、但最影响结果的一环。最开始我图省事,用固定 512 字符带 64 重叠的窗口切所有文档,结果问答时经常出现两种诡异情况:
- 答案明明在文档里,但检索出来的片段永远停在标题前,上下文不完整。
- 一个 Markdown 表格被切成两半,模型看到的是半个表头加几行数据,根本没法回答"这张表第三列最大值是多少"。
后来我改成了结构感知切分:先按 Markdown 标题层级把文档切成"章节块",再在章节块内部按段落和句子切到目标大小,最后对边界做小幅重叠。核心代码大概是这样:
import re def split_by_structure(text: str, chunk_size: int = 800, overlap: int = 80): # 第一步:按标题层级切分 parts = re.split(r'(?m)^(#{1,4})\s+(.*)$', text) sections = [] current_heading = "" buf = [] for i in range(0, len(parts), 3): if i + 2 < len(parts): # parts[i] 是标题和正文之间的普通文本 ...这个思路的要点是:标题是天然语义边界,切分器要先尊重文档作者自己的逻辑结构,而不是让算法用固定长度强行切。对代码块、表格、代码片段,我单独做了保护逻辑:如果一段代码块长度小于 chunk_size,整个 code block 不拆;超过就按行拆,绝不让半个 Python 函数成为检索片段。
切分之后的块数,直接决定了向量化的成本。我第一批导入 800 多篇 Markdown 和 PDF,切完之后大概 6500 块,这个量级在本地是完全吃得消的,也让我意识到:个人知识库场景根本不需要上分布式向量引擎,单机 Qdrant 绰绰有余。
2.2 Embedding 模型选型:中文场景别随便用通用英文模型
说实话,embedding 模型的选型是最不能偷懒的部分。我最初拿社区里流行的通用模型试过,中文语义检索效果只能说勉强能看,尤其是面对"文章里的概念解释"这类问题,召回结果经常挂在字面相似但语义无关的片段上。
我最后用的是BGE-M3,理由有三个:
- 中文效果好,而且不只是向量检索,还支持稀疏词权重检索,这给混合检索留下了空间。
- 对指令型 query 有优化,RAG 场景下语义匹配更稳。
- 有量化和降维选项,本地跑起来不烧爆内存。
模型获取这一块,我建议从 ModelScope 下载,国内网络环境比从原仓库拉稳得多:
git lfs install git clone https://www.modelscope.cn/AI-ModelScope/bge-m3.gitBGE-M3 输出维度是 1024,全量加载大概要吃掉 4~6GB 内存(float32),如果机器内存紧张,可以转成 float16 或做 256 维输出降维,损失一点精度但内存占用直观下降。我实测降维到 512 维后,检索准确率掉得不多,内存省了一半。
提示:批量向量化时一定开 batch。BGE-M3 用 batch=32 跑 6500 块,GPU 上大约 8 分钟;不开 batch 逐条跑,够你泡两杯咖啡。
2.3 混合检索 + RRF 融合:向量和关键词打架时这样调和
腾讯那篇架构文章里,知识检索部分反复强调了相关性,但没有细讲用了什么具体算法。我按自己的经验选了混合检索:向量检索抓语义,BM25 抓精确关键词,最后用 RRF 融合排序。
为什么不用纯向量?因为我踩过一个非常典型的坑。有一次我导入了一批技术文档,里面频繁出现"上下文""模型"这类词。我问"模型上下文窗口怎么设置",纯向量检索召回的前三块里有两块是讲"上下文管理",跟窗口设置完全无关。BM25 对"窗口""设置"这类精确词非常敏感,能把真正含"窗口设置"的段落捞上来。把两边结果融合后,排名才变正常。
RRF 融合的核心公式非常简单,不需要调权重:
def merge_rrf(vector_results, keyword_results, k=60, top_n=5): scores = {} for rank, doc_id in enumerate(vector_results, start=1): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) for rank, doc_id in enumerate(keyword_results, start=1): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) return sorted(scores, key=scores.get, reverse=True)[:top_n]k 取 60 是经验值,控制了两路结果的均衡程度。两条召回各取 top 30,融合后再取 top 5 进重排。这个流程在单机上跑,向量检索大约 120ms,BM25 大约 60ms,融合几乎不耗时。
2.4 重排模型别省,但别照搬官方的重排规模
官方架构里重排是独立模块,这在我本地版里是唯一一个我完全没有删减的模块。因为没有重排的话,混合检索出来的前 5 块里至少有 1 块是"相关但无用"的段落——比如概括性引言,信息密度极低。把这种段落喂给模型,它就会一本正经地给你一个空泛的答案。
我用的重排模型是bge-reranker-v2-m3,FlagEmbedding 里有现成封装:
from FlagEmbedding import FlagReranker reranker = FlagReranker('bge-reranker-v2-m3', use_fp16=True) def rerank(query: str, docs: list[str], top_n: int = 5): pairs = [[query, doc] for doc in docs] scores = reranker.compute_score(pairs, normalize=True) ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True) return [doc for doc, _ in ranked[:top_n]]重排引入的延迟是肉眼可见的:30 条输入,GPU 上要 400~600ms。但换来的是进入模型 prompt 的内容质量显著提升。我的原则是:宁可多等半秒,也不让模型对着一条"看似相关实则没用"的片段硬编答案。
重排之后还有一步兜底逻辑:如果 top 1 的重排分数低于阈值,直接回答"本地知识库没找到相关内容"。这一步被很多人忽略,但恰恰是防止幻觉最有效的手段。你让模型在没资料的时候硬答,它一定给你编一个看起来合理的答案。
3. 手写 Copilot 编排:意图路由、工具注册和多轮循环
3.1 为什么不让 LangChain 管编排
标题说"手搓",这里是最能体现手搓感的地方。最开始我确实想过直接上现成的 Agent 框架,省事。但试过一轮之后就放弃了,原因是:现成框架在"知识库问答"这个核心场景上给了太多无关抽象,而在"意图判断要够快、工具调用要可控"这种实际需求上又给得太少。
最后我保留的第三方组件只有:FastAPI、Qdrant、FlagEmbedding、一个 OpenAI 兼容的模型客户端。Agent 编排、工具注册、上下文管理、流式输出全部自己写。自己写的最大好处是,每一步发生的逻辑你都能在脑子里复现,出了问题不用去翻框架源码。
编排层我拆成三块:意图路由、工具注册表、循环执行器。
3.2 意图路由:一个轻量分类器比复杂 Prompt 更稳
本地版要回答三类东西:基于知识库的问答、需要调用工具的指令类任务、纯闲聊。在官方架构里,意图识别属于 Copilot 会话层的核心能力,用的是模型推理加规则兜底。我在本地版里做了一个精简版:用几个典型示例加单标签分类。
def route_intent(query: str, history: list[dict]) -> str: prompt = f"""你是意图路由器。从以下选项中选择一个最合适的标签,只输出标签名。 - tool_task:用户明确要求执行操作(打开、查找文件、计算、提醒等) - knowledge_qa:用户问的是某份文档里的内容或概念 - chat:闲聊、问候、无法确定的其他情况 示例: "我的简历里项目经历怎么写的" -> knowledge_qa "帮我统计一下这个目录里有多少个文件" -> tool_task "你好" -> chat 现在判断:{query}""" label = llm_call(prompt, temperature=0) return label.strip()不要小看这个简单设计。我试过让模型直接输出"是否调用工具"的判断,结果本地 7B 模型经常把"知识库问答"误判成"工具调用"——因为知识库检索本身也是一种工具。改成单标签分类,并把示例固定住之后,误判率降到了个位数。
还有个关键细节:这个路由调用用的模型应该是快模型,不要走重模型。在本地我让路由固定走 7B 量化模型,temperature 固定 0;等真到了需要长上下文推理的知识问答,再让主模型登场。
3.3 工具注册表:装饰器一挂,自动进候选集
工具调用最烦的一点是:每加一个新工具,就要改循环逻辑、改 prompt、改参数校验。我借鉴官方架构里"插件化"的思路,写了个极简注册表,新增工具只需要一个装饰器:
tool_registry = [] def register_tool(name, description, parameters: dict): def decorator(func): tool_registry.append({ "name": name, "description": description, "parameters": parameters, "func": func, }) return func return decorator @register_tool( name="search_local_files", description="搜索本地文件,支持按文件名和关键词过滤", parameters={ "type": "object", "properties": { "path": {"type": "string", "description": "要搜索的目录或文件路径"}, "keyword": {"type": "string", "description": "文件名关键词"} }, "required": ["path"] } ) async def search_local_files(path: str, keyword: str = ""): ...执行循环就用经典 ReAct 思路:模型先决定要不要调工具、调哪个、参数是什么;我代执行后把结果继续塞回上下文;最多循环 4 轮,防止模型陷入工具调用死循环。这个限制很重要,本地小模型在工具调用上特别容易"上瘾",有时候会为了回答一个简单问题连环调用三次工具。我做了三个保险:
- 最大轮数限制:默认 4 轮,超出就停止并返回当前结果。
- 每轮工具结果截断:单次工具返回超过 2000 字符就截断,避免把上下文撑爆。
- 工具参数严格校验:模型给的参数缺字段时,直接返回错误信息给模型,让它自己修正,而不是抛异常。
3.4 上下文截断与长文本:本地模型最现实的问题
本地模型跟官方 API 模型有一个本质差异:上下文窗口再大,本地跑推理时 KV Cache 占用也和输入长度成正比。我在本机上用 14B 量化模型,4K 输入的推理速度还能接受,一旦塞到 8K 以上,生成速度肉眼可见地下降。
所以我把上下文管理做成了一条规则链:
- 短期对话历史最多保留 10 轮,超出后把最旧的对话做摘要压缩。
- 检索回来的文档片段有总预算上限,默认 2500 字符,超出部分按重排分数截掉。
- 工具调用历史单独存储,不进主对话历史,只在模型需要时才回填。
这套规则看起来粗糙,但在单机场景下非常实用。官方可以靠分布式缓存和模型网关来动态调窗口,本地版的答案只能是"提前控制输入规模"。
4. 流式输出与双重记忆:体验上容易被忽视的两块
4.1 流式输出:工具调用阶段别让用户看到"思考中"的碎碎念
用过 Copilot 类产品的人都有体会,流式输出是刚需。但这里有一个很容易踩的坑:如果你把模型原始 token 直接往客户端推,工具调用阶段会出现一堆 JSON 参数片段刷屏。用户看到的是"这是什么鬼东西",体验直接崩掉。
我的解决方式是分阶段控制流式输出:
- 意图路由阶段:不流式,内部静默完成。
- 工具执行阶段:推送一个固定说明,比如"正在执行:搜索文件",然后静默。
- 最终生成阶段:走 SSE 逐 token 推送。
FastAPI 里用 StreamingResponse 写起来很简单:
from fastapi.responses import StreamingResponse import json async def stream_answer(query: str, history: list[dict]): async def event_gen(): async for delta in llm.stream(query, history): yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n" return StreamingResponse(event_gen(), media_type="text/event-stream")前端只需要按data:行解析即可。这里有个小细节:SSE 推送时要做ensure_ascii=False,中文才能正常显示,不然前端收到的全是\uXXXX转义。
4.2 短期记忆与长期记忆:我分成了两张表
官方架构文章里把记忆拆成了"会话内上下文"和"用户长期档案"两层。本地版我照样做了区分,只是实现更简单:
- 短期记忆:存在 SQLite 的
session_messages表,按 session_id 组织,保存最近 30 轮对话。 - 长期记忆:每次会话结束后,把对话内容做摘要,向量化后存入长期记忆集合。之后新会话启动时,先检索与当前问题相关的历史摘要,作为背景注入。
长期记忆的实现跟知识库检索是同一套代码:切分、向量化、检索。区别只是向量化对象从文档变成了对话摘要。这算是我从官方架构里学到的比较有价值的一个设计——把"记忆"本身当成一种可检索的知识。
但长期记忆有个副作用我差点没兜住:记忆污染。如果上一轮对话里你问过"某个项目什么时候上线",摘要里存了这个信息;下一轮你问"那个项目的 deadline",模型可能直接拿过期记忆回答,而不是去最新的知识库验证。我的处理是:长期记忆只作为"补充背景",检索分数低于 0.4 就不注入;注入时会明确告诉模型这句的置信度不高,需要以知识库为准。
4.3 断点续聊:本地版也要有
官方那种多端产品,会话状态同步是基础能力。本地版我一开始觉得没必要做断点续聊,反正机器不关机。直到有一次我躺床上用手机问知识库问题,合上电脑第二天再打开,发现会话上下文还挂着前一天的内容,模型把新旧问题混在一起答,才意识到该封口了。
我的方案是给每个会话加一个is_closed标记。会话空闲超过 30 分钟自动关闭,下次打开会新建 session_id。新 session 里只注入长期记忆,不注入上一个 session 的完整对话。用户如果想延续完整上下文,可以在界面里手动点"续聊",这时才会把历史引出。
注意:关闭会话这个动作不要做成"You sure?"的二次确认,直接把旧会话归档到历史列表即可。产品上少一个弹窗,体验好一半。
5. 本地版实测:效果、资源占用,以及哪些官方设计不能照搬
5.1 实测链路数据:这个延迟水平能接受
先交代环境:我主力测试机是一台有 RTX 4090 的 Linux 主机,模型部署用本地推理引擎加载 Qwen2.5-14B 量化版;笔记本上跑 FastAPI 服务和 Qdrant 容器,偶尔会把路由请求打到远端的 API 模型做对比。
文档集是 800 多篇 Markdown 和 PDF,切分后约 6500 个片段,索引总量不大。几条关键链路的实测数据如下:
| 环节 | 耗时 / 备注 |
|---|---|
| 全量向量化建索引(6500 块,batch=32) | 约 9 分钟 |
| 混合检索(Qdrant + BM25 + RRF,top 5) | P99 < 150ms |
| 重排 30 条 -> top 5(GPU 4090) | 平均 450ms |
| 意图路由(7B 量化,temperature=0) | 平均 220ms |
| 首 token 延迟(14B 本地模型) | 1.2s ~ 2s |
| 首 token 延迟(远端 API 模型) | 600ms ~ 900ms |
从体验上说,混合检索加重排的总延迟在 600ms 以内,还没到体感阈值;真正慢的是本地 14B 模型的生成速度。如果你想要"打字机一样流畅"的体验,要么上更大的显存跑 32B 模型,要么接受远端 API 的依赖。两者选其一,没有免费的午餐。
5.2 资源占用:个人知识库真不需要大集群
整个本地版跑下来,资源占用比我想象中低很多。Qdrant 容器大概吃 1GB 内存,BGE-M3 加载后约 5GB 内存,重排模型约 2GB,FastAPI 服务几十 MB。如果知识库规模控制在几万块以内,一台 32GB 内存的机器完全能撑住,甚至不需要独立显卡——BGE-M3 和重排模型在 CPU 上只是慢一两倍,还没有到不可用的地步。
真正吃资源的是生成模型。我试过三种部署方式:
- 本地 7B 量化:内存占用 6~8GB,速度尚可,但复杂推理能力明显不足。
- 本地 14B 量化:需要 12GB 以上显存,推理质量接近可用,但回答速度偏慢。
- 远端 OpenAI 兼容 API:延迟最低,质量最高,代价是依赖网络和密钥。
我最终的方案是:路由层做了 provider 配置,默认走本地 14B,遇到长文档总结或复杂推理任务自动切到远端 API。这其实就是官方模型路由层的穷人版。
5.3 踩坑实录:这些坑如果你也打算手搓,大概率会遇到
整理一下我这两天踩过比较值得说的坑,按杀伤力排序。
第一个坑是分块边界切断代码块。第一次全量导入我就发现,回答 Python 相关问题时,检索出来的片段经常是半个函数。排查后发现是我的切分器按字符硬切,把def xxx():和后面的函数体切到了两个块里。修复办法是切分时对代码块单独走"按行切"逻辑,同时保证如果函数体短于 chunk_size 就整块不拆。
第二个坑是Qdrant 的 payload 索引问题。我一开始往 Qdrant 里写文档时,把来源路径、文件名、章节号都放在 payload 里,但没建索引。数据量小的时候不觉得,数据到了 6000 多块之后,带过滤条件的检索明显变慢。给 payload 字段建了索引后,检索耗时直接降了一个量级。单机场景这个优化往往被忽略。
第三个坑是重排模型和向量模型不能混用同一个显存池。我开始为了让代码简单,想着同一个模型服务里同时加载两个 BGE 模型,结果显存爆了,后加载的模型被换到 CPU 上跑,重排耗时从 450ms 飙到 5 秒。最后老老实实分成两个独立进程,各自加载,互不挤占。
第四个坑是Tool call 的参数幻觉。本地 7B 模型在生成工具参数时,偶尔会编一个根本不存在的路径。比如我不知道给它什么权限,它自己编了一个/Users/me/Downloads/report.pdf就当参数传进来。后来我在工具执行前加了一层参数校验,对路径类参数先做存在性检查,不存在就直接返回错误给模型,让它重新给参数。
5.4 官方设计里最不能照搬的部分
很多人在本地复刻云端产品时,最大的幻觉是"官方有什么,我就要有什么"。我可以坦诚地说,腾讯那篇文章里的几个关键设计,本地版不仅不需要照搬,照搬了反而会更差。
首先是多级缓存。官方要做多级缓存是因为要扛万人级 QPS,本地版一个用户在跑,缓存命中率上不去,还引入缓存失效、一致性问题。我用进程内字典 + SQLite 两层就封顶了。
其次是分布式会话同步。官方要考虑用户在不同设备间切换,所以会话状态必须跨节点同步。本地版最开始我把会话状态也放进了 SQLite 的共享内存模式,结果每次访问都要查一次库,完全没必要。改成纯内存 Session 后,响应速度肉眼可见地提升。
再次是模型网关与限流。官方模型网关要负责鉴权、计量、负载均衡、模型容灾,本地版唯一需要做的就是在多个 provider 之间做切换。所以我只保留了一个get_llm()函数,内部按配置选择本地或远端,半小时就写完了。云端的模型网关是基础设施,本地的模型路由是一个 if-else,两者本质不同。
不过有一点我觉得官方做得很对,并且我在本地原样保留的,就是:意图判断、知识检索、工具执行、生成回复这四步必须严格解耦。本地版里这四个步骤分别有独立函数和独立日志,一旦回答质量有问题,我看日志就能直接定位是检索没召回、重排没选对、还是模型生成跑偏。这个调试体验远比一把梭的链路舒服。
5.5 后续能补的东西和我的个人体会
如果让我把本地版 ima.copilot 再往上推一步,我接下来想补两个方向:一个是把长期记忆从摘要级提升到实体级,比如自动抽取"项目名、截止时间、关键结论"这类元信息,做成可筛选的结构化记忆;另一个是给文档解析层加上增量更新,让知识库能识别本地文件的变更,只重新索引有变动的部分,而不是每次全量建索引。
回过头来看这一个周末的"手搓"过程,我最真实的体会是:架构文章的价值不在于让你模仿那套复杂的分布式系统,而在于帮你建立一种拆问题的思路。官方把知识工作流拆成接入、引擎、编排、模型、记忆五层,我在本地用一个 FastAPI 服务加几个 Python 文件把同样的逻辑复现了出来。云端的复杂度是来解决问题规模问题的,本地的简洁是解决问题效率问题的,两者不冲突。
如果你也想照着 ima 的架构做一个本地助手,我建议你从这里开始:先不管模型路由和长期记忆,先把你自己的文档切好、做好混合检索、跑通"问题 -> 检索 -> 答案"这条最短链路。只要这条链路是准的,后面加工具、加记忆、加流式输出都只是时间问题;但如果你一上来就去接 Agent 框架和记忆系统,最后大概率被一堆抽象概念淹没,连"为什么回答不准"都定位不到。