面向知识库的 Progressive Agentic RAG。先改写并分流,需要证据时再进入 ReAct 深读。会话原文留在 checkpoint,发给模型的是按稳定性排好的视图,方便前缀缓存命中。
对话图
入口是 agents/graph.py。一次问答按下面的顺序走:
flowchart LR
START --> prepare_context --> manage_memory --> query_understand
query_understand -->|问候 / 闲聊 / 纯图纯附件| generate --> END
query_understand -->|需要工具| agent
agent -->|tool_calls| tools --> collect_sources --> agent
agent -->|直接作答| END
| 节点 | 做什么 |
|---|---|
prepare_context |
抽出本轮问题和近期问答;清掉上一轮意图、图片描述、引用来源 |
manage_memory |
判断要不要写交接摘要,写入「记住:…」,召回跨会话背景 |
query_understand |
改写检索词并做意图分类,决定进 ReAct 还是直接生成 |
agent |
绑定本轮真正能用的工具,按上下文布局调用主模型 |
tools |
执行工具。大结果先外置或蒸馏,失败最多重试 3 次 |
collect_sources |
只扫当前轮的检索类工具消息,汇总 [[cN]] 用的来源 |
generate |
不走工具时的一次生成,例如问候、纯图片说明 |
系统提示在 prompts/agent_system.py:事实必须来自检索或联网,命中后要用 list_chunks 读全文,不能把上一轮的工具结果当成这轮的证据。
查询理解
agents/nodes/query_understand.py 产出 rewrite_query、intent、image_description。改写只补指代和省略,不把「最近 / 比较火」这类线索改掉,也不把问题改成「请再检索…」。
意图默认读下一个 token 的 logprobs(models/decision.py)。厂商不支持时退回 JSON 结构化输出。有图片时再调一次 VLM 写图片描述。
路由不交给主模型猜:
greeting/chitchat/image_only/doc_only走generatekb_search/clarification/summarize在已选知识库时进 ReActweb_search仅在本轮开了联网时进 ReAct- 智能体绑了专业工具(如生成 PPT)时,非跳过类意图进 ReAct
这次调用是独立的短请求,不跟主对话共用同一条前缀。
检索
services/retrieval_service.py 把一次 doc_retrieval 做完,图上不拆成向量节点和关键词节点。顺序是:
- 多库分组。 每个知识库用自己的 embedding 模型。
- 混合召回。 同一条查询打两条 SQL:向量余弦和
pg_trgm词法,再用 RRF 合并。 - 不够再扩展。 命中太少时才做 LLM multi-query 或本地同义词扩展,结果继续 RRF。
- 父块回捞。 命中子块时取回父段,避免答案落在切块边界上。
- 精排和多样性。 rerank 之后可选 MMR。
工具层分工:doc_retrieval 做语义加词法,grep_chunks 做正则锚定,list_chunks 按 cN 或 chunk_id 读全文,get_document_info 只看元数据。query_knowledge_graph 和 web_search / web_fetch 是补充,不能代替深读。
上下文管理
主对话(agent / generate)的请求在 utils/context_layout.py 里组装。越稳定的内容越靠前,这样身份和技能的前缀可以持续命中缓存。支持显式缓存的厂商在系统级末尾能打断点;其他 OpenAI 兼容接口靠这段字节保持不变。
一次请求从前往后是六段:
- 系统级。 身份、能力边界、这个智能体绑定的技能目录。语言和联网开关不写进这段。
- 任务级。 接在同一条系统消息后面:是否选了知识库、联网、图谱、回答语言。工具 schema 按这些开关裁剪,调了会失败的工具不放进来。
write_plan、generate_pptx、get_stored_data不依赖这些开关,仍然保留。用户改了开关,只从这里失效。 - 交接摘要。 更早对话的压缩,来自
session_summary。放在历史上面,不进系统提示,也不写成聊天记录。还没压缩过就空着。 - 历史。 只保留最近
stm_keep_turns轮(默认 8)已经结束的对话,按原文送出。checkpoint 里更早的消息还在,只是这次不发给模型。 - 本轮上下文。 紧挨最新一条用户消息,不进 checkpoint。只有改写词、图片描述、本轮点名的技能、长期记忆
asker_background。同一轮工具循环里这段不再改。 - 本轮。 正在进行的问题,以及这一轮新的检索和工具结果,接在最后。
query_understand、意图分类、写交接摘要、语义压缩各自请求,不跟这条前缀共用。
工具结果与对话压缩
checkpoint 里的原文不改。体积控制发生在写入工具消息时,以及上下文快满时的交接。
外置。 utils/tool_result_store.py。正文超过 8000 字、顶层数组超过 10 条,或工具标记了整份外置时,消息里只留 {__stored, __refId, __summary, __hint}。全文进 Postgres tool_result_refs。模型需要原文时调用 get_stored_data,这个工具不在智能体勾选列表里,返回值不再二次外置。
语义压缩。 utils/semantic_compressor.py。外置前若原文超过 10000 字,先蒸馏到 2000 字以内,只留后续推理要用的 ID、名称、数值和结论。失败则截断并标明是降级结果,不把半截 JSON 当成全文。
交接。 utils/conversation_compaction.py。上一轮 prompt_tokens 达到上下文窗口的 85% 时触发,目标压到大约 30%。产物是交接文档,不是一段自由摘要:用户原始请求、分阶段做了什么、放弃的路径、外置结果的引用索引。引用索引由代码从 __refId 抽出,不经模型。切分不会从一条工具消息中间下刀。当前这一问不进归档。
记忆
会话内和跨会话是两套东西。
会话内在 LangGraph checkpoint 里,由 manage_memory 在理解问题之前更新。
| 内容 | 放哪 | 作用 |
|---|---|---|
| 全部消息 | checkpoint | 可回放。发给模型时只带最近 N 轮 |
| 交接摘要 | 请求视图里、历史上面 | 覆盖被挤出窗口的更早轮次 |
| 工作记忆 | 只给 query_understand |
最近计划和近期问答要点,不进主模型请求 |
summary_upto_message_id |
state | 标记已经交接过的位置,避免同一段反复摘要 |
跨会话在 Postgres memory_items 和文档亲和表,按用户隔离(utils/long_term_memory.py)。
- 只有「记住:…」这类明示才会写入,类型是画像、兴趣或事实。
- 回答引用过的文档累计次数,同一文档至少命中 2 次才进入「常查资料」。
- 每轮读出画像、兴趣和常查资料,拼成
asker_background,放进本轮上下文,也给查询改写用。它用来消解指代,不当成问题本身。 - 没有后台自动抽取,也没有按需搜索记忆的工具。
工具与技能
工具目录在 tools/catalog.py。智能体只保存工具名,可执行体不进数据库。本轮没选知识库、没开联网或没开图谱时,对应工具不会出现在 schema 里。
| 类别 | 工具 |
|---|---|
| 规划 | write_plan |
| 知识库 | doc_retrieval、grep_chunks、list_chunks、get_document_info、query_knowledge_graph |
| 联网 | web_search、web_fetch |
| 生成 | generate_pptx |
| 运行时注入 | read_file、execute_skill_script、get_stored_data |
技能在 skills/。系统提示里只放绑定技能的名称、说明和 /skills/<name>/SKILL.md。正文要模型先 read_file 再按说明书做。read_file 在 API 进程内只读已绑定技能。带脚本的技能仍由 execute_skill_script 在 Docker 里执行,输入在 /workspace/input,产出在 /workspace/output。用户这一轮点名的技能写在本轮上下文里,不改技能目录那段前缀。
文档摄取
上传接口把文件放进对象存储并入队。Celery 任务(api/tasks.py)再解析、切块、向量化、入库。上传接口和后台任务是两次独立调用,中间不共享一条 trace。
解析器在 ingestion/parser/,覆盖常见办公文档、图片和音频。切块在 chunkers/,可按标题或父子块。向量按批写入 stores/ 的 chunks 表,维度跟着知识库所选的 embedding 模型走。开启图谱的知识库会再把块送去抽实体关系,写入 Neo4j。
引用
检索工具返回带文件名、chunk_id、document_id 或 URL 的来源。回答里用 [[cN]] 紧跟在对应事实上,N 是本轮结果的序号。不要用 [1],也不要在文末堆引用。collect_sources 只收集当前轮,上一轮的 [[cN]] 不算数。前端用 ks_citations 把角标显示成文档名。
模型
models/ 区分本地 Ollama 和远程 OpenAI 兼容接口。远程厂商在 models/providers.py 登记,包括硅基流动、OpenAI、阿里云、智谱、DeepSeek、Kimi、火山、混元、千帆、OpenRouter、Jina,以及自定义兼容口。
类型有 Embedding、Rerank、KnowledgeQA、VLLM、ASR。运行时先用显式模型 ID,否则用该类型的默认模型,再否则用环境变量。API key 用 AES-256-GCM 加密,密钥是 MASTER_KEY。内置模型、默认模型、仍被知识库引用的模型不能删除。
服务与界面
FastAPI 在 api/main.py。会话流式问答、知识库、文档、模型、智能体、技能和评测各自一条路由。对话图跑在 API 进程里,检查点优先用 Postgres,连不上时退回内存。文档处理和评测跑在 Celery。
前端是 frontend/ 里的 Vue 应用:对话、知识库、智能体、技能、模型和评测。
评测在 evals/,覆盖检索、生成和意图分类。意图评测只跑 query_understand,不跑整张 ReAct 图。
可观测性
配置了 Langfuse 公钥和私钥后才上报,否则全部空操作(utils/observability.py)。
一次会话问答是一条 session_chat trace。图节点、每次模型调用、每次工具调用会变成子 span,并带上耗时。检索内部的 embedding、两条 SQL、rerank,以及联网时的各次 HTTP,目前还包在对应工具 span 里。工具重试和 Celery 任务重试没有单独的 span。摄取的上传接口和后台解析也是两条互不关联的 trace。