孔夫子 AI 聊天助手:模拟面试笔记
孔夫子 AI 聊天助手
1. 项目概览
定位:全栈 AI 学习项目,展示 Agent 编排、RAG 检索、MCP 协议、记忆系统、工程韧性等 AI 工程核心技术。
一句话:基于 DeepSeek API 的孔夫子 AI 聊天助手,能查论语原文、能上网搜索、能记住用户说的话。
技术栈:FastAPI + SSE 流式 → LangGraph ReAct Agent → MCP 协议层(手写 JSON-RPC 2.0)→ 3 个 MCP Server → ChromaDB + SQLite
个人侧重:后端 AI 应用(Agent / RAG / MCP / 记忆 / 韧性 / 安全),前端只了解 SSE 流式传输部分。
1.1 系统架构(5 层)
| 层 | 内容 |
|---|---|
| 数据层 | ChromaDB(HNSW)+ SQLite + 论语 JSON(20章512条) |
| RAG 层 | BGE Embedding + BM25 → RRF 融合(k=60) → BGE-Reranker 精排 |
| MCP 协议层 | 手写 JSON-RPC 2.0,3 个独立 Server,工具定义自动转 OpenAI 格式 |
| Agent 层 | LangGraph StateGraph ReAct 循环 + Skill 系统 + 三级降级 |
| 接入层 | FastAPI + SSE 流式 + JWT 鉴权 + structlog 全链路 trace_id + 限流 |
1.2 核心能力(4 点)
- Agent 自主决策 + Skill 系统 — LangGraph ReAct 循环,LLM 自主决定调不调工具、调哪个、调几次;3 种 Skill(讲学/诗词/辩论)独立 System Prompt 和工具权限
- 混合 RAG + MCP 协议框架 — 手写 JSON-RPC 2.0,3 个 Server 6 个工具;RAG 两阶段:BGE + BM25 → RRF 融合 → Cross-Encoder 精排
- 记忆系统(长短期) — 滑动窗口 + LLM 增量摘要 + Agent 工具式记忆(remember/recall)+ 自动画像提取
- 韧性与安全 — 三级降级 + 熔断器 + 指数退避重试;三层限流 + 输入过滤 + Prompt 加固 + 输出角色检测
1.3 题目总览
高频必考 ★★★ | 常见 ★★ | 可能追问 ★
| # | 题 | 模块 | 频度 | 类型 |
|---|---|---|---|---|
| Q2 | ReAct 一个完整循环怎么走? | Agent | ★★★ | 原理 |
| Q13 | RAG 全链路怎么走?每一步做什么? | RAG | ★★★ | 原理 |
| Q14 | Bi-Encoder 和 Cross-Encoder 到底差在哪? | RAG | ★★★ | 原理 |
| Q20 | Context window 有限,长对话怎么处理? | 记忆 | ★★★ | 设计 |
| Q24 | 重试、熔断、降级三者怎么配合? | 韧性 | ★★★ | 设计 |
| Q1 | Agent 和传统 chatbot 的核心区别? | Agent | ★★ | 原理 |
| Q3 | 为什么 LangGraph?什么时候手写 while 就行? | Agent | ★★ | 设计 |
| Q10 | 为什么自己实现 MCP 而不是用 LangChain Tool? | MCP | ★★ | 设计 |
| Q15 | 混合检索怎么互补?举例说明 | RAG | ★★ | 原理 |
| Q16 | RRF 的 k 值怎么理解?调大调小会怎样? | RAG | ★★ | 原理 |
| Q21 | 为什么增量摘要而不是每次重跑? | 记忆 | ★★ | 设计 |
| Q25 | Jitter 是什么?不加会怎样? | 韧性 | ★★ | 原理 |
| Q31 | DeepSeek API 怎么调用的?和 OpenAI 有什么区别? | LLM | ★★ | 实践 |
| Q36 | Agent 这种非确定性组件怎么测试? | 测试 | ★★ | 实践 |
| Q4 | Agent 最多迭代几次?超时/死循环怎么处理? | Agent | ★ | 实践 |
| Q5 | 工具调用失败了 Agent 怎么处理? | Agent | ★ | 设计 |
| Q6 | Agent 怎么决定调哪个工具? | Agent | ★ | 原理 |
| Q7 | Skill 和写多个 Agent 有什么区别? | Skill | ★ | 设计 |
| Q8 | 诗词赏析 Skill 为什么禁用 web_search? | Skill | ★ | 设计 |
| Q9 | Agent 能主动切换 Skill 吗? | Skill | ★ | 设计 |
| Q11 | 怎么保证工具参数不被 LLM 乱填? | MCP | ★ | 实践 |
| Q12 | 两个 Server 注册了同名工具怎么办? | MCP | ★ | 实践 |
| Q17 | 为什么 BGE 系列?生态考量是什么? | RAG | ★ | 选型 |
| Q18 | 怎么评估 RAG 效果好还是差? | RAG | ★ | 实践 |
| Q19 | 从 512 条扩到 100 万条,哪里先撑不住? | RAG | ★ | 设计 |
| Q22 | 记忆的 SQLite 和论语的 ChromaDB 为什么不同? | 记忆 | ★ | 选型 |
| Q23 | 自动画像提取为什么不直接存到 Memory? | 记忆 | ★ | 设计 |
| Q26 | 熔断器半开为什么只放 1 个探测? | 韧性 | ★ | 原理 |
| Q27 | 三级降级,L1 为什么用同一个 System Prompt? | 韧性 | ★ | 设计 |
| Q28 | 为什么 L0→L1 只捕获 502/503/504? | 韧性 | ★ | 设计 |
| Q29 | 提示词注入防御三层分别解决什么问题? | 安全 | ★ | 设计 |
| Q30 | 为什么三层限流不用 Redis 而是内存? | 安全 | ★ | 选型 |
| Q32 | 为什么 Prompt 有的放 YAML、有的嵌代码? | LLM | ★ | 设计 |
| Q33 | 流式 SSE 怎么实现的? | LLM | ★ | 实践 |
| Q34 | trace_id 怎么在全链路传递? | 日志 | ★ | 原理 |
| Q35 | 结构化日志和 print 有什么区别? | 日志 | ★ | 原理 |
| Q37 | 单元测试和 E2E 的划分逻辑? | 测试 | ★ | 实践 |
| Q38 | 为什么 SQLite?迁移 PostgreSQL 怎么考虑? | 数据库 | ★ | 选型 |
| Q39 | CI 为什么拆成两个 Workflow? | 部署 | ★ | 实践 |
| Q40 | 生产部署还需要考虑什么? | 部署 | ★ | 实践 |
| Q41 | 这个项目最大的技术挑战是什么? | 综合 | ★★ | 开放 |
| Q42 | 如果重新设计,会改什么? | 综合 | ★ | 开放 |
| Q43 | 部署到生产环境还缺什么? | 综合 | ★ | 开放 |
按题型分类:
- 原理理解(10 题):Q1, Q2, Q6, Q14, Q15, Q16, Q25, Q26, Q34, Q35
- 设计决策(18 题):Q3, Q5, Q7, Q8, Q9, Q10, Q19, Q20, Q21, Q23, Q24, Q27, Q28, Q29, Q32, Q35
- 工程实践(9 题):Q4, Q11, Q12, Q18, Q31, Q33, Q36, Q37, Q39, Q40
- 技术选型(4 题):Q17, Q22, Q30, Q38
- 开放题(3 题):Q41, Q42, Q43
2. Agent 系统
2.1 对 Agent 的理解
核心公式:Agent = LLM 推理能力 × 工具调用能力 × 循环决策能力
为什么 ReAct 而非单一 Function Calling?
- 单次 FC:用户提问 → LLM 输出要调的函数 → 执行 → 结果塞回去 → 回复。一次性,LLM 没有"再想想"的机会
- ReAct(Reasoning + Acting):思考→行动→观察→再思考→再行动→直到觉得够了→最终回答。每一步能看到前面执行结果,决定下一步
为什么 LangGraph 而非手写 while 循环?
- StateGraph 声明式节点+条件边,天然支持多工具并行调用
- 状态管理、超时、终止条件由框架处理,不用自己写
- Agent 节点做推理决策,Tools 节点执行工具调用,条件边判断"还有 tool_calls 且没超轮数 → 继续 / 否则 → 结束"
2.2 Skill 系统
Skill = "角色 + 工具 + 规则"的组合包(dataclass):
Skill:
name # "teaching" | "poetry" | "debate"
display_name # "夫子教诲" | "诗词赏析" | "经学辩论"
system_prompt # 完整 System Prompt,LLM 拿到直接可用
allowed_tools # 工具白名单,[] = 全部可用
三个 Skill 共享同一套孔子底层人格(身份/语气/行为准则),在知识侧重、回答风格、工具权限上差异化:
| Skill | 特点 | 工具 |
|---|---|---|
| 夫子教诲(默认) | 循循善诱,优先查论语原文 | 全部开放 |
| 诗词赏析 | 《诗经》为主,赋比兴分析法 | 禁用 web_search |
| 经学辩论 | 每条论断至少两处原文互证 | 全部开放 |
怎么起作用:启动注册(SkillRegistry 单例)→ 请求解析(resolve(skill_name) 取 Prompt 和工具白名单)→ 注入 Agent(System Prompt + 过滤后的工具列表)。切换完全在 Agent 外部完成,Agent 代码不动。
Agent 能主动读 Skill 吗? 不能。Agent 从头到尾只看到 System Prompt 和工具列表,不知道"我在什么模式"。这是刻意的——Agent 职责是"用好给我的 Prompt 和工具",角色管理是系统层职责。
2.3 Agent / Skill / MCP 三者关系
用户选 Skill → 系统注入 Prompt + 过滤工具列表
↓
Agent(ReAct 循环思维框架)
↓ 需要工具时
MCPClient(统一入口)
↙ JSON-RPC ↘
AnalectsServer WebSearchServer MemoryServer
| 角色 | 是什么 | 职责 |
|---|---|---|
| Agent | 思维框架 | 循环推理——拿消息→思考→调工具→观察→输出。不关心角色和工具实现 |
| Skill | 业务逻辑 | 决定行为边界——给什么 Prompt、开放哪些工具 |
| MCP | 工具治理 | 统一注册、发现、调用。Agent 只需要 discover_tools() 和 call_tool() |
| JSON-RPC | 通信协议 | Server-Client 之间的标准消息格式 |
面试题
Q1: Agent 和传统 chatbot 的核心区别是什么?
传统 chatbot:代码写死流程——意图识别→检索→拼 prompt→回复。LLM 是被动的文本生成器,控制流在代码手里。
Agent:LLM 自主决策。我们项目的 agent_graph.py 实现了 LangGraph 的 StateGraph,只有两个节点(_agent_node 负责调 LLM 做决策,_tools_node 负责执行工具),一条条件边(_should_continue 判断"还有 tool_calls 且没超轮数?→ 回 agent 节点继续 / 否则 → END")。每次 LLM 看到上一步结果后重新决定下一步,控制流在 LLM 手里。
代码体现:state["messages"] 是累积的消息列表,每轮 _agent_node 追加一条 assistant 消息,_tools_node 追加 N 条 tool 消息。LLM 每次被调用时看到的是完整消息历史,所以能根据累积信息做决策。
Q2: ReAct 模式一个完整循环是怎么走的?带上实际的消息格式(★最高频)
以"孔子怎么看管仲?"为例。实际的消息流转是:
初始 State:
messages = [
{"role":"system", "content":"你是孔子..."},
{"role":"user", "content":"孔子怎么看管仲?"}
]
iteration = 0
↓ _agent_node: 调 chat_with_tools(messages, tools=6个工具定义)
LLM 看到所有工具描述,判断需要检索 → 返回:
assistant_msg = {
"role": "assistant",
"content": "吾当查证论语原文。", ← 可选,LLM 可以同时输出文字
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "hybrid_search", "arguments": '{"query":"管仲"}'}
}]
}
追加到 messages,现在 messages 长度=3
↓ _should_continue: 检查 last_msg 有 tool_calls → 返回 "tools"
↓ _tools_node: 从 MCPClient 调 call_tool("hybrid_search", {"query":"管仲"})
→ AnalectsServer._hybrid_search() → RAG 全链路 → 返回5条原文文本
→ 构造 tool_msg = {"role":"tool", "tool_call_id":"call_1",
"content":"《八佾篇》: 子曰管仲之器小哉...\n《宪问篇》: ..."}
追加到 messages,iteration 变为 1
↓ 边 "tools" → "agent",回到 _agent_node
同一个 _agent_node 再次被调用,此时 messages 已经包含:
[system, user, assistant(含tool_calls), tool(检索结果)]
LLM 看到检索结果,判断"只有论语角度,需要补充外部资料" → 再次返回 tool_calls:
{"tool_calls": [{"function": {"name": "web_search",
"arguments": '{"query":"孔子 管仲 评价"}'}}]}
↓ _should_continue → "tools"
↓ _tools_node → call_tool("web_search", ...) → Tavily API → 追加 tool_msg
↓ 回到 _agent_node,这次 LLM 判断信息足够 → 只返回 content 无 tool_calls:
{"role":"assistant","content":"管仲者,齐之贤相也。子曰'管仲之器小哉'...仁乎?..."}
↓ _should_continue: 无 tool_calls → "__end__"
↓ graph.invoke() 返回 final_state,从最后一条 assistant 消息提取 reply
LangGraph 的 reducer(`lambda x, y: x + y`)保证每个节点返回的 messages 是追加而非覆盖,所以整个对话历史自动累积,LLM 每轮都看到完整上下文。
Q3: 为什么用 LangGraph?什么时候手写 while 就行?
手写 while:工具 1-2 个,循环简单(调一次工具就回复),比如"查数据库→拼结果→回复"。
该用 LangGraph 的信号:
- 工具数量 5+(我们 6 个工具跨 3 个 Server)
- 需要并行工具调用(
_tools_node用ThreadPoolExecutor并行执行多个 tool_call,手写 while 并行逻辑要自己管理线程) - 需要条件路由(
_should_continue根据 state 动态选 tools/END,不是固定流程) - 需要 checkpoint/中断/恢复(LangGraph 内置支持,本项目没用到但架构留了口子)
实际代码中,整个图构建只有 15 行(StateGraph → add_node ×2 → set_entry_point → add_conditional_edges → add_edge → compile),手写 while 也要差不多行数但控制流会分散在循环体内。
Q4: Agent 超时/死循环怎么防止?
三道防线,写在 _should_continue 条件边里:
-
迭代上限
MAX_ITERATIONS = 5:_tools_node每次执行后iteration += 1,_should_continue判断iteration >= 5→ 直接走__end__。此时 state 里有完整消息历史,run_agent_graph取最后一条 assistant 消息的 content 作为回复(即使 LLM 还想要调工具也被强制结束) -
总超时
MAX_DURATION_SECONDS = 120:在_should_continue里检查time.time() - start_time > 120→ 同样走__end__ -
单工具超时
TOOL_TIMEOUT_SECONDS = 15:_run_single_tool在ThreadPoolExecutor里执行,future.result(timeout=15)→ 超时抛异常 → 返回"[工具执行超时或异常]"作为 tool 消息内容。Agent 循环不中断,LLM 看到这个错误决定下一步
死循环场景(LLM 反复调 hybrid_search 但每次都不满意)→ 迭代上限兜底。实际很少出现——每次调工具消耗 token,LLM 倾向于 2-3 轮就结束。
Q5: 工具调用失败了 Agent 怎么处理?
不抛异常,返回错误文本。两层保护:
MCPServer._handle_tools_call() 中 handler 抛异常 → 捕获后返回 TOOL_EXECUTION_ERROR,message = "Tool 'xxx' execution error: <具体错误>"
MCPClient.call_tool() 中 Server 返回 error response → 返回文本 "[工具调用失败] <错误信息>"
_tools_node 中 future.result(timeout=15) 超时 → 返回文本 "[工具执行超时或异常] <e>"
所有这些文本都作为 {"role":"tool","content":"<错误文本>"} 追加到 messages。LLM 下一轮推理时看到错误文本 → 自己决定:换工具?换参数?还是用自己的知识回答?Agent 循环不中断。抛异常会终止整个图 → 用户什么都看不到。
Q6: Agent 怎么决定调哪个工具?
没有代码路由逻辑——全是 LLM 自己看工具描述决定的。
_agent_node 调 chat_with_tools(messages, tools=所有工具定义),tools 参数是 discover_tools() 返回的 OpenAI 格式列表,每个工具包含:
{"type":"function","function":{
"name":"search_by_keyword",
"description":"精确关键词匹配...适合'巧言令色'这类精确查找", # ← LLM 看这个
"parameters":{"type":"object","properties":{"keyword":{"type":"string"}},"required":["keyword"]}
}}
用户说"巧言令色" → LLM 读 description,判断这是精确关键词 → 选 search_by_keyword。用户说"什么是仁" → LLM 读 hybrid_search 的描述"语义+关键词混合,适合自然语言问题" → 选它。
工具描述是给 LLM 读的说明书,不是给程序员读的注释。写工具描述的标准:告诉 LLM 什么时候该用我、什么时候不该用我。
Q7: Skill 和写多个 Agent 有什么区别?
三个独立 Agent → 三套 run_agent_graph() 调用、三套错误处理、三套降级链。加第 4 个 → 再写一套。
Skill 模式:一个 Agent 图编译一次全局复用。变的是输入——run_agent() 里 _resolve_skill(skill_name) 拿到 system_prompt 和 allowed_tools → 传给 run_agent_graph(system_prompt=..., tools=过滤后的列表, ...)。Agent 图只关心"给我的 Prompt 是什么、工具有哪些",不关心这些是从哪个 Skill 来的。
加第 4 个 Skill:在 builtin.py 加一个 Skill(...) dataclass → SkillRegistry 注册 → 完成。Agent 代码一行不动。
Q8: 诗词赏析 Skill 为什么禁用 web_search?
poetry_skill = Skill(
name="poetry",
allowed_tools=["hybrid_search","search_analects","search_by_keyword"], # 没有 web_search
)
_agent_loop_with_tools() 里拿到 allowed_tools 后做过滤:
if allowed_tools is not None:
tools = [t for t in all_tools if t["function"]["name"] in allowed_tools]
这样 LLM 根本看不到 web_search 这个工具——不是"看到但不让用",是"压根不知道有这个能力"。对 LLM 来说,可用的工具就 3 个。
为什么这么做?两个原因:
- 角色自洽:孔夫子是《诗经》最高权威,让他上搜索引擎查现代人的赏析 → 角色崩塌
- Token 省:少一个工具定义 = 少 ~200 tokens 的 prompt 长度
Q9: Agent 能主动切换 Skill 吗?
不能。_resolve_skill() 在 run_agent() 入口处执行一次,skill 解析为 system_prompt + allowed_tools 后传入 Agent 图。Agent 循环内部没有任何"换 Skill"的能力——它甚至不知道"Skill"这个概念的存在,只看到一段 System Prompt 和一个工具列表。
如果 Agent 能自己切 → 用户选了"诗词赏析",Agent 觉得需要辩论自己切到 debate 模式 → 用户困惑。角色一致性必须由用户控制,不能被 Agent 的推理优化掉。
3. MCP 协议与工具治理
3.1 Function Calling vs MCP
| Function Calling | MCP | |
|---|---|---|
| 层面 | LLM API 输出格式 | 工具治理框架 |
| 解决的问题 | LLM 说"我要调工具" | 工具从哪来、怎么执行、结果怎么回 |
| 格式 | OpenAI tools/function 参数 | JSON-RPC 2.0 |
| 关系 | MCP 的 inputSchema 和 OpenAI parameters 都是 JSON Schema,直接复用,零转换 |
3.2 MCP 原理
基于 JSON-RPC 2.0,三个核心原语:
| 方法 | 作用 |
|---|---|
initialize |
Client↔Server 握手,协商协议版本,声明 capabilities |
tools/list |
Client 查询 Server 提供了哪些工具 → 返回 ToolDefinition[] |
tools/call |
Client 调具体工具,name + arguments → content[] |
3.3 三层实现
- protocol.py — JSON-RPC 2.0 消息格式(Request/Response/ToolDefinition)+ 标准错误码
- server.py — MCPServer 基类:
register_tool()注册 +handle_request()方法路由分发 + 参数校验;子类只需在__init__里注册自己的工具 - client.py — MCPClient 聚合层:
discover_tools()(遍历所有 Server 收集工具 → 转 OpenAI 格式)+call_tool(name, args)(找到拥有该工具的 Server → 执行 → 提取文本返回);全局单例
当前 transport 层是进程内函数调用,但 JSON-RPC 消息格式完整保留——换 HTTP+SSE 就是标准 MCP,协议层不动。
3.4 为什么需要 MCP
- 解耦 — 加工具不改 Agent 代码,Agent 只看到标准工具描述
- 统一治理 — 不同来源的工具通过同一套协议暴露、错误处理、返回格式
- 协议兼容 — inputSchema 和 OpenAI Function Calling 格式直接复用
一句话:MCP = 工具的 USB 接口。
面试题
Q10: 为什么自己实现 MCP 而不是用 LangChain 的 Tool?
看实际代码的差异。
LangChain Tool 的方式:
from langchain.tools import tool
@tool
def search_analects(query: str) -> str:
"""从论语中语义检索"""
return retrieve(query)
问题:函数签名、装饰器、返回类型都是 LangChain 风格。换 Agent 框架 → 所有 @tool 定义都要改。
我们的 MCP 方式:
# server.py — 工具注册
self.register_tool(
ToolDefinition(name="search_analects", description="...", inputSchema={...}),
handler=self._search_analects # 普通 Python 函数,不依赖任何框架
)
# client.py — 发现 + 调用
tools = mcp.discover_tools() # → OpenAI 格式
result = mcp.call_tool("search_analects", {"query":"仁"}) # → str
工具 handler 是纯函数:(dict) -> str。不依赖 LangChain、不依赖 FastAPI、不依赖 Agent 框架。换 Agent 框架 → 只需要新的 Client 来调 discover_tools() 和 call_tool(),Server 和 handler 不动。
还有一个对比:LangChain Tool 的错误处理各自为政(有的抛异常、有的 return “error”),MCP 的 handle_request → _handle_tools_call → 统一错误码(-32001 工具不存在、-32002 执行错误)→ 统一返回格式 {"content":[{"type":"text","text":"..."}]}。调用方不需要猜这个工具会怎么报错。
Q11: 怎么保证工具参数不被 LLM 乱填?
_validate_args(arguments, input_schema) 在 handler 执行前做校验(在 server.py:271 的 _handle_tools_call 里调用):
# 1. 检查必填字段 — LLM 可能漏传
required = input_schema.get("required", [])
for field in required:
if field not in arguments or arguments[field] is None:
return f"缺少必填参数: {field}"
# 2. 类型检查 + 安全转换
for field, schema in props.items():
expected_type = schema.get("type", "")
if expected_type == "string" and not isinstance(val, str):
# 数字/布尔 → 字符串是安全的,"123" 和 123 对检索来说等价
if isinstance(val, (int, float, bool)):
arguments[field] = str(val)
else:
return f"参数 {field} 应为字符串,实际类型为 {type(val).__name__}"
elif expected_type == "integer" and not isinstance(val, int):
# "5" → 5,5.0 → 5,但 "abc" → 报错
...
LLM 有时会传 top_k: "5" 而不是 top_k: 5,或者漏传 query 参数。校验拦住后返回 INVALID_PARAMS 错误(code=-32602),文本如 "缺少必填参数: query" → LLM 看到 → 下一轮修正参数重新调。不实现完整 JSON Schema,只做最常见的两道检查。
Q12: 如果两个 Server 注册了同名工具怎么办?
# client.py register()
for existing in self._servers:
if existing.server_info.name == server.server_info.name:
return # 同名 Server 跳过,不重复注册
后注册的 Server 同名 → 跳过。不同 Server 但工具同名 → 先注册到哪个 Server,call_tool 遍历时先匹配到哪个就用哪个。日志 warn 提示冲突。
当前 3 个 Server(analects/web/memory)6 个工具命名互不重叠。这是协议设计时就预留的边界——未来接入第三方 MCP Server 时需要考虑命名空间。
4. RAG 检索系统
4.1 基本定位与解决问题
做什么:让"孔夫子"从 512 条论语原文中检索内容回答问题,非凭 LLM 记忆编造。
解决什么:
- LLM 知识截止 + 古籍训练占比低 → 凭记忆复述经常张冠李戴
- 幻觉 → 强制回答必须有原文依据
- 可溯源 → 精确到"《八佾篇》第三条"
4.2 两阶段检索架构
用户查询
│
├──→ BGE Embedding 向量检索 (语义相似, top-10)
│ │
├──→ BM25 关键词检索 (精确匹配, top-10)
│ │
└──→ RRF 融合 (排名融合去重, top-15)
│
↓
BGE-Reranker Cross-Encoder 精排 (逐对打分, top-5)
Bi-Encoder vs Cross-Encoder:
| Bi-Encoder (Embedding) | Cross-Encoder (Reranker) | |
|---|---|---|
| 输入方式 | query 和 doc 独立编码 | query 和 doc 拼接输入 |
| 注意力 | query 和 doc 永不见面 | Transformer 内交叉注意力 |
| 精度 | 中等 | 高 |
| 512条全量 | 毫秒级 | ~30秒 |
| 角色 | 粗筛(512→15) | 精排(15→5) |
Bi-Encoder 快:doc 向量预计算存 ChromaDB,检索只算一次 query 向量。
Cross-Encoder 慢:每条候选都要完整推理。所以必须两阶段。
为什么先融合再精排? 先各自精排会丢失"某路排低但另一路排高"的文档。先融合再精排,Reranker 看到的是"两路都覆盖"的候选集。
延迟:Embedding(~20ms) + ChromaDB(<5ms) + BM25(<5ms) + RRF(<1ms) + Reranker 15条(~200ms) ≈ 230ms
4.3 检索细节
RRF 融合:score(d) = Σ 1 / (k + rank_i(d)),k=60
为什么不直接加权?向量分(0.3~1.0)和 BM25 分(0~50+)尺度不同,没法加。RRF 只看排名,排名天然可比。k=60 来自 Cormack 2009 论文,让排名衰减平滑,中尾部文档不被忽略。k 越大排名衰减越慢。
BM25 字符级回退:古文特有问题。jieba 基于现代汉语,"学而时习之"→["学而","时","习","之"],用户搜"学习"→["学习"],在古文语料中不存在。短 query(≤4字)追加单字:["学习","学","习"],防漏检。
jieba 精确模式:搜索引擎模式切出冗余长词,精确模式短词更多 → 召回更高。
score 计算:1 / (1 + cosine_distance),映射到 (0, 1]。
4.4 模型选型
- BGE-large-zh-v1.5:C-MTEB 中文检索榜前列,1024 维,专为检索优化
- BGE-Reranker-v2-m3:同家族 Cross-Encoder,配套使用行为一致性高
- 从 ModelScope 下载(国内镜像,HuggingFace 不稳定)
- 不降维:1024 维 × 512 条够用,百万级才考虑 PCA
4.5 分块策略
按章分块 ❌ overlap。论语每条是完整语义单元且短(30-100 字),硬拆破坏语义,overlap 不需要。
数据量演进:1 万条 OK → 10 万条 BM25→ES → 100 万条 ChromaDB→Milvus、Reranker GPU 加速。
4.6 工程取舍
- ChromaDB 而非 Milvus:512 条零配置够用
- BM25 内存索引而非 ES:512 条 rank_bm25 建索引 < 50ms
- Embedding/Reranker 懒加载+单例+双重检查锁:两模型 ~3.3GB,必须复用
- hybrid_search 空了:Agent 可换
search_analects或search_by_keyword换角度重试,体现 ReAct 再决策
4.7 评估
当前无系统离线评估(学习项目取舍)。如果做:标注 50-100 个问答对 → Recall@5、MRR、NDCG → 对比各环节增益。RAG 失效场景:问的不在论语里、短查询信息量不足。
面试题
Q13: RAG 全链路怎么走的?从代码调用链讲(★最高频)
入口在 AnalectsServer._hybrid_search(),完整调用链:
def _hybrid_search(self, arguments: dict) -> str:
from app.rag.fusion import rrf_fusion
from app.rag.keyword_search import keyword_search
from app.rag.reranker import rerank
from app.rag.retriever import retrieve
query = arguments["query"]
# Step 1: 两路粗筛并行
vec = retrieve(query, top_k=10) # BGE向量 → ChromaDB HNSW
bm25 = keyword_search(query, top_k=10) # jieba分词 → BM25打分
# Step 2: RRF 融合去重 → top-15
fused = rrf_fusion(vec, bm25, top_k=15)
# Step 3: Reranker 精排 → top-5
final = rerank(query, fused, top_k=5)
return _format_results(final) # 5条原文拼成文本字符串
每一步的实际数据流:
Step 1 — retrieve():
query="孔子怎么看管仲?" → BGE编码 → [0.023, -0.145, ...] (1024维)
→ ChromaDB query(embedding, n_results=10) → HNSW 近邻搜索
→ 返回 10 条: [{"id":"八佾篇_22","text":"子曰管仲之器小哉...","score":0.85}, ...]
score = 1/(1+cosine_distance),值域 (0,1]
Step 1 — keyword_search():
jieba.cut("孔子怎么看管仲?") → ["孔子","怎么","看","管仲","?"]
→ BM25Okapi.get_scores(tokens) → np.argsort[-10:] → 返回 10 条
[{"id":"宪问篇_16","text":"...","score":12.5}, ...]
score 是 BM25 原始分,值域 0~50+
Step 2 — rrf_fusion(vec, bm25, top_k=15):
对每路每个 rank 计算 1/(60+rank+1)
"八佾篇_22": vec排1→1/61=0.0164 + bm25排5→1/65=0.0154 = 0.0318
→ 全部文档按 RRF 分排序 → 取前 15
Step 3 — rerank(query, fused, top_k=5):
CrossEncoder.predict([[query, doc1], [query, doc2], ...]) → 15次推理
→ 每对返回一个 sigmoid 分数 (0~1)
→ 按分排序 → 取前 5 → _format_results 拼成文本
每一步选择原因:向量+BM25 互补(语义 vs 精确);RRF 不用加权(两路分尺度不同);Reranker 只跑 15 条(512 条全量要 30 秒)。
Q14: Bi-Encoder 和 Cross-Encoder 到底差在哪?从代码讲(★最高频)
本质差别在于输入方式和注意力范围。
Bi-Encoder(embedder.py):
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# query 和 doc 各自独立编码,互不看见
q_vec = model.encode("什么是仁", normalize_embeddings=True) # [1024维]
d_vec = model.encode("子曰克己复礼为仁", normalize_embeddings=True)
score = dot(q_vec, d_vec) # 余弦相似度
doc 向量可预计算存 ChromaDB → 检索时只算一次 query 向量 + 512 次点积 → 毫秒级。
Cross-Encoder(reranker.py):
model = CrossEncoder("BAAI/bge-reranker-v2-m3")
# query 和 doc 拼接输入:[CLS] query [SEP] doc [SEP]
scores = model.predict([["什么是仁", "子曰克己复礼为仁"],
["什么是仁", "子曰巧言令色鲜矣仁"]])
# → [0.92, 0.15]
每对 (query, doc) 都要完整推理一次——Transformer 内部 query 的每个 token 都通过注意力看到 doc 的每个 token。精度高但 512 对 × 每对推理 = 30 秒。所以只对粗筛后的候选集做精排。
为什么 Cross-Encoder 不能预计算? Bi-Encoder 的 query 和 doc 各自独立,doc 向量和 query 无关;Cross-Encoder 的输入是 (query, doc) 对,doc 换了 query 就得重新算。这是两种架构的根本差异。
Q15: 混合检索(向量+BM25)怎么互补?从实际数据看
两个实际例子:
例 1 — 精确匹配场景:
query = "巧言令色"
向量检索 top-3: "巧言令色鲜矣仁"(score 0.89), "刚毅木讷近仁"(0.72), "仁者其言也讱"(0.68)
BM25 检索 top-3: "巧言令色鲜矣仁"(score 8.2), "巧言令色足恭"(7.1), "巧言乱德"(5.3)
向量找到了一堆讲"仁"的但只有一个精确命中;BM25 三个全是精确匹配。两路融合 → “巧言令色鲜矣仁”(两路都认)排第一,“巧言令色足恭"和"巧言乱德”(BM25认但向量不认)也在候选里。
例 2 — 语义理解场景:
query = "什么是仁"
向量检索 top-3: "克己复礼为仁"(0.91), "己所不欲勿施于人"(0.87), "仁者爱人"(0.85)
BM25 检索 top-3: "仁者爱人"(score 11.2), "仁远乎哉"(9.8), "人而不仁"(8.5)
向量找到了"己所不欲勿施于人"——这句不含"仁"字但确实在讲仁,BM25 只召回含"仁"字的章句。两路互补 → 不丢失"爱人"这类不含关键词但语义相关的。
Q16: RRF 的 k 值怎么理解?
score = 1/(k + rank),k=60 来自 Cormack 2009 论文。
数值直觉(单路贡献):
| rank | k=1 | k=60 | 差距 |
|---|---|---|---|
| 1 | 0.500 | 0.0164 | 30x |
| 3 | 0.250 | 0.0159 | 16x |
| 5 | 0.167 | 0.0154 | 11x |
| 10 | 0.091 | 0.0143 | 6x |
k=1:排名之间的差距巨大,头部垄断。某文档向量排第1 → RRF=0.5,就算 BM25 排第50(0.02)也拉不回来。
k=60:排名衰减极慢。两路都排前 10 的文档总分 ≈ 0.030,一路排第1另一路排第50 ≈ 0.025,前者胜出——体现"多路共识"。
项目实际用 k=60,不需要调。调的是各路 top_k 配比(当前向量10+BM2510=20→融合15→精排5)。
Q17: 为什么 BGE 系列?生态考量是什么?
三个维度:
-
任务匹配度:BGE-large-zh-v1.5 是 retrieval-oriented(专为检索优化),不是通用 Embedding。m3e 偏通用文本表示,text2vec 512 维区分度不够长文本
-
配套生态:BGE-Reranker-v2-m3 同家族 Cross-Encoder。训练目标一致 → Embedding 觉得相关的,Reranker 大概率也认。混搭(BGE Embedding + Cohere Reranker)可能粗筛 Top-5 全被精排打低分 → 精排失效
-
工程便利:ModelScope 国内镜像下载,不受 HuggingFace 网络限制。懒加载 + 全局单例:
_get_model()里双重检查锁,首次调用才加载,之后复用
Q18: 怎么评估 RAG 效果好还是差?
坦率讲没有系统的离线评估。如果做:构建 50-100 个 (问题, 正确答案chunk_id) 标注对 → 三指标:
- Recall@5:正确答案出现在 top-5 中的比例。核心指标,低=检索漏了
- MRR(Mean Reciprocal Rank):正确答案排名的倒数均值。排第1得1.0,排第3得0.33。越高越靠前
- NDCG@5:考虑排名位置的相关性,排前面的相关性更高
对比实验:纯向量 vs 纯 BM25 vs RRF融合 vs RRF+Reranker,量化每个环节的 Recall 增益。同时监控延迟 P50/P95,精度不能以延迟为代价。
Q19: 从 512 条扩到 100 万条,哪里先撑不住?
按瓶颈出现顺序:
-
BM25 内存索引:rank_bm25 所有文档 token 放内存 → 100 万条 = 几 GB → 换 Elasticsearch(独立分词+倒排索引+分布式)
-
Reranker 延迟:粗筛候选可能从 15 条增加到 50 条 → Cross-Encoder 逐条推理 50 次 → 800ms+ → GPU 加速(batch predict)或换 API 推理(Cohere/阿里云)
-
ChromaDB 单机:HNSW 百万条约几 GB 内存还行,千万级 → Milvus 分布式
-
分块策略:文档变长(非论语短章句) → overlap 分块 + 层次化索引(先召回文档→再精确定位段落)
不需要架构重写,是组件升级。
5. 记忆与上下文管理
5.1 三层记忆架构
第一层:滑动窗口(最近 5 轮原文保留)
↓ 溢出
第二层:增量摘要(5 轮之前 → LLM 压缩 → [前情提要] 注入)
↓ 用户主动存 / Agent 主动查 / 系统自动提
第三层:长期记忆(remember/recall 工具 + 自动画像提取)
核心问题:context window 有限,对话可以无限长。
5.2 滑动窗口 + 增量摘要
滑动窗口:最近 5 轮(10 条消息)完整保留原文。
为什么不直接截断? 超过 5 轮就丢 → 用户感觉"助手失忆了"。
增量摘要:
第 1-5 轮 → 原文保留
第 6 轮 → 第1轮溢出 → LLM 生成摘要 S1
第 7 轮 → 取 S1 + 第2轮 → 合并成 S2
第 N 轮 → 只取"新增溢出"(2条) + 旧摘要 → LLM 合并
增量而非每次重跑全部溢出:省 token、更快。
注入:[前情提要] system 消息插在消息列表最前。上限 300 字 ≈ 200 tokens。
用户感知:助手记得聊过大方向,细节有所模糊——比完全失忆智能。
5.3 Agent 工具式长期记忆
| 工具 | 触发方 | 用途 | 安全边界 |
|---|---|---|---|
remember |
用户主动 | 存关键信息 | 只能记用户明确要求的 |
recall |
Agent 自主 | 语义检索历史记忆 | Agent 决定何时查 |
BGE Embedding 向量化 → SQLite(embedding JSON)→ recall 时逐条余弦相似度 → Top-K。≤3 条直接返回全部。
为什么 SQLite 而非 ChromaDB? <50 条记忆,Python 循环 < 1ms。同是向量检索,数据量决定索引策略。
5.4 自动画像提取
Memory: 用户主动要求记住 | Profile: 系统自动提取身份/水平/偏好
每轮对话后:extract_profile() → LLM 返回 JSON → merge_profile() 合并 → format_profile_for_prompt() 注入 System Prompt。Token 开销 < 6%(~200 chars / 50 tokens)。
5.5 设计理念
Agent 不感知"记忆系统"的存在。它只看到 System Prompt 里多了画像、消息前多了 [前情提要]、工具列表多了两个记忆工具。记忆是"悄悄"注入的,各层职责分离。
面试题
Q20: Context window 有限,长对话怎么处理?从代码实现讲(★最高频)
三层方案,都在具体代码里:
第1层 — 滑动窗口(conversation.py:79-98):
MAX_HISTORY_MESSAGES = 10 # 5轮×2条
def get_chat_history(db, conversation_id):
messages = (db.query(Message)
.filter(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(MAX_HISTORY_MESSAGES) # 只取最近10条
.all())
messages.reverse()
return inject_summary([{"role": m.role, "content": m.content}
for m in messages], db, conversation_id)
第2层 — 增量摘要(summarizer.py):
每次保存新消息后触发 maybe_summarize() → 检测总轮数 > 5 → 只取"新增溢出"(通常 2 条)→ 调 LLM 合并旧摘要+新溢出 → 存 Conversation.summary。注入时 inject_summary() 在消息列表最前插 {"role":"system","content":"[前情提要] ..."}。
第3层 — 长期记忆(memory_server.py 的 remember/recall 工具 + profile.py 的自动画像)。Agent 循环内自主决定何时调 recall。
不是简单的截断——那让用户感觉失忆。核心洞察:人的记忆不均匀——最近聊的记得清楚,很久前的大致脉络记得。系统模拟的正是这种自然衰减。
Q21: 为什么增量摘要而不是每次重跑?从 token 消耗算
对话到第 20 轮时的成本对比:
全量重跑:第 1-15 轮(30 条消息)全发给 LLM → _format_messages() 每条截断 300 字 → prompt ~3000 tokens × 每次 $0.0005/1K ≈ $0.0015。90% 做的是重复工作——旧摘要已经是对第 1-14 轮的精确压缩。
增量:只取"新增溢出"第 15 轮(2 条消息)+ 旧摘要 → prompt ~400 tokens ≈ $0.0002。核心逻辑在 _generate_summary(new_content, existing):
# 增量合并而非从头摘要
prompt = tpl.format(existing=existing or "(无)",
new_messages=new_content[:2000])
增量策略"信任旧摘要 + 只合并新内容",做到 7-8 倍成本节省。
Q22: 记忆的 SQLite 和论语的 ChromaDB,为什么不同?
同是向量检索,同一个 BGE 模型,但数据量完全不同 → 索引策略不同。
论语:512 条 × 1024 维 → 需要 HNSW 近邻索引实现亚毫秒检索 → ChromaDB。
记忆:单用户 < 50 条 → recall_facts() 直接 Python 循环算余弦相似度 < 1ms。而且有特判:
if len(rows) <= 3:
return "\n".join(f"- {r.fact_text}" for r in rows) # 直接返回,不走向量
加 ChromaDB 对 50 条徒增 HNSW 维护开销。工程判断的核心:不是选最好的工具,是选刚好够用的。
Q23: 自动画像提取为什么不直接存到 Memory?
两个表职责不同。Memory = 用户主动要求记住(“帮我记住我下周考《论语》”),可信度高。Profile = 系统被动观察(“用户是考研学生”),是推测。
混在一起 → recall(“学习”) 返回"用户下周要考试"(用户主动存的事实)+ “身份是考研学生”(系统推断)→ LLM 分不清信任级别。分离存储 = 分离信任级别。Profile 不走向量检索,而是直接 format_profile_for_prompt() 注入 System Prompt——它不需要"搜索",是全局上下文。
6. 工程韧性与降级
6.1 整体协作
请求进来
├── 熔断器检查 → 熔断中?→ 直接拒绝(毫秒级)
│ ↓ 放行
├── 指数退避重试 → 瞬时故障 → 最多 3 次
│ ↓ 全部失败
└── 三级降级 → L0(Agent+工具) → L1(纯LLM) → L2(固定回复)
三者分工:重试对付瞬时故障,熔断对付持续故障,降级兜住最终体验。
6.2 指数退避重试
固定间隔 → 惊群效应(并发请求同时失败→同时重试→又同时失败)。指数退避+Jitter(±25%) 把请求打散到不同时刻。
只重试瞬时故障(RateLimitError/APITimeoutError/APIError),不重试 AuthenticationError。
重试在底层(最接近 API),熔断在顶层(调用入口)。重试 3 次全失败 → 熔断器算 1 次失败。
6.3 熔断器
三态状态机:
CLOSED(正常)──连续失败 >= 5──→ OPEN(拒绝所有请求)
│
等 30s 超时
↓
HALF_OPEN(只放行 1 个探测)
↙ ↘
探测成功 探测失败
↓ ↓
CLOSED(恢复) OPEN(重新计时)
关键设计:半开只放 1 个请求(不一下全放开);线程安全锁保护状态转换;支持装饰器/手动/上下文管理器。
6.4 三级降级
| 级别 | 能力 | API 依赖 | 体验 |
|---|---|---|---|
| L0 | Agent + MCP + RAG | DeepSeek | 查原文、搜索、最佳 |
| L1 | 纯 LLM(同 System Prompt) | DeepSeek | 能回但无引用 |
| L2 | 固定文言回复 | 零 | 不报错页面 |
关键:L0→L1 只捕获 502/503/504;降级不降人格(L1 同 Prompt);L2 硬兜底。
面试题
Q24: 重试、熔断、降级三者怎么配合的?从调用链讲(★最高频)
看 client.py 的 chat() 函数,完整调用链:
def chat(messages, temperature=1.1, max_tokens=1024):
# 第1关: 熔断器检查
if not _llm_circuit_breaker.allow_request():
raise HTTPException(503, "夫子小憩片刻,请稍后再问") # 毫秒级拒绝
try:
# 第2关: 重试装饰器(底层,自动3次指数退避+Jitter)
response = _chat_completion_create(messages, temperature, max_tokens)
_llm_circuit_breaker.on_success() # 成功 → 重置熔断器
return response.choices[0].message.content
except (APIError, APITimeoutError, RateLimitError, ...) as e:
_llm_circuit_breaker.on_failure() # 第3关: 记1次失败
raise _handle_api_error(e) # 映射为 HTTPException
_handle_api_error 的映射:RateLimitError→429、APITimeoutError→504、APIError→502。
然后到 Agent 层的 run_agent():
def run_agent(user_message, history, skill_name, profile_text):
try:
return _agent_loop_with_tools(...) # L0
except HTTPException as e:
if e.status_code in (502, 503, 504): # 只捕获上游不可用
return _simple_chat_reply(...) # L1
except Exception:
return "夫子思之良久,未能作答..." # L2
时序:熔断器判断(毫秒级)→ 放行后走重试(1s→2s→4s+Jitter)→ 重试全失败记1次熔断计数 → 5次后熔断器OPEN → 后续请求直接 503 → 触发 L1 → L1也熔断 → L2。
关键设计:重试在底层 _chat_completion_create,熔断在顶层 chat()。重试3次全失败对熔断器算1次——它们是同一语义操作的3次尝试。
Q25: Jitter 是什么?不加会怎样?
def _calc_delay(base, factor, attempt, max_delay):
delay = base * (factor ** attempt) # 指数: 1→2→4→8
delay = min(delay, max_delay) # 封顶60s
jitter = delay * random.uniform(-0.25, 0.25) # ±25%随机
return max(0, delay + jitter)
4 个并发请求同时因 RateLimitError 失败 → 无 Jitter:都等恰好 1s → 同一毫秒同时重试 → 可能再次全部触发限流 → 死循环。这就是惊群效应。
有 Jitter:4 个请求分别等 0.78s、1.12s、0.93s、1.21s → 分散在 400ms 窗口内 → 服务器负载平滑。
Q26: 熔断器半开为什么只放 1 个探测?
代码在 CircuitBreaker.allow_request():
if self._state == CircuitState.HALF_OPEN:
if self._half_open_count < self.half_open_max_requests: # 默认1
self._half_open_count += 1
return True
return False # 其他请求拒绝
放 5 个 → 服务刚恢复但不稳定 → 5 个请求又打挂 → 重新熔断。1 个 = 最小探测代价。失败 → 只影响1个用户,重新OPEN计时。成功 → 服务确认恢复,直接切CLOSED全量放行。用最小代价验证服务是否恢复——熔断器模式的经典实践。
Q27: 三级降级,L1 为什么用同一个 System Prompt?
def _simple_chat_reply(user_message, history, skill_name):
registry = get_skill_registry()
skill = registry.resolve(skill_name)
sys_prompt = skill.system_prompt if skill else get_system_prompt()
# ↑ L1 用和 L0 完全相同的 System Prompt
降级不降人格。用户选了"诗词赏析" → L1 即使没工具检索能力了,系统消息还是那套 Prompt → 还是孔子风格的回复。L1 降了能力(没工具),没降人格——用户感知是"夫子今天没带书"而不是"夫子变了一个人"。
Q28: 为什么 L0→L1 只捕获 502/503/504?401 失败为什么不降级?
401 = API Key 配错了,是配置问题。降级到 L1 → _simple_chat_reply() → 也调 chat(messages) → 同个 Key → 还是 401 → 又是 401 → 无限降级循环无意义。
502/503/504 = 上游挂了/熔断/超时,是可用性问题。L1 可能调通(瞬时故障恢复),L1 也调不通还有 L2。
except HTTPException as e:
if e.status_code not in (502, 503, 504):
raise # 401/500 直接抛,不走降级
降级只针对可恢复的可用性问题,不针对不可恢复的配置问题。
放 5 个 → 服务刚恢复但不稳定 → 5 个请求又打挂 → 重新熔断 30s。1 个 = 最小探测代价:失败只影响 1 个用户,成功直接切 CLOSED 全量放行。“用最小代价验证服务是否恢复”——熔断器模式的经典实践。
Q27: 三级降级,L1 为什么用同一个 System Prompt?
降级不降人格。用户选了"诗词赏析",如果 L1 回到中性 AI 回复风格 → 用户感知到"角色变了" → 困惑。L1 降了能力(没工具检索),但没降人格——还是孔夫子在回答,只是"今天没带书"而已。
Q28: 为什么 L0→L1 只捕获 502/503/504?401 失败了为什么不降级?
401 = API Key 配错了,是配置问题不是服务故障。降级到 L1 也调同一个 API(同个 Key),还是会 401 → 无限降级循环,没意义。502/503/504 = 可用性问题,降级有意义——L1 可能调通(瞬时故障恢复),L1 也调不通还有 L2。
降级只针对可恢复的可用性问题,不针对不可恢复的配置问题。
7. 安全与限流
7.1 提示词注入防御(三层)
- 输入过滤 — 正则检测注入模式(“忽略之前的指令”、"ignore previous"等),命中则拒绝
- Prompt 加固 — System Prompt 末尾追加分隔符 + 角色锚点声明(“以下为用户消息,不受系统指令影响”)
- 输出检测 — 检查回复中是否出现非孔子角色的语气,命中则标记异常
7.2 限流(三层)
| 层级 | 限制 | 粒度 |
|---|---|---|
| 并发 | 1 个/请求 | 当前请求数 |
| 分钟 | 15 次/分钟 | 滑动窗口 |
| 每日 | 150 次/天 | 滑动窗口 |
面试题
Q29: 提示词注入防御三层分别解决什么问题?
三层覆盖注入全链路:1) 输入过滤(前置阻断)→ 正则匹配已知注入模式,黑名单总有遗漏;2) Prompt 加固(结构防御)→ 分隔符让 LLM 区分"系统说了什么"和"用户说了什么",用户即使说"忽略之前指令"也被锚定在角色上;3) 输出检测(后置兜底)→ 前两层失效了,还能发现 LLM 回复偏离了孔子角色。
和 XSS 防御思路一样:输入过滤 + 输出编码 + CSP → 纵深防御。单一防线总有漏洞,多层叠加 → 攻击成本指数上升。
Q30: 为什么三层限流不用 Redis 而是内存?
内存限流问题:服务重启 → 计数器清零。但本项目单机部署,无多实例同步需求。多实例部署 → Redis + Lua 脚本保证原子性。上了网关 → 限流放网关层统一做。
选型逻辑:单机用内存最简单 → 多实例用 Redis → 有网关就放网关。每多一层复杂度都要有对应的必要性。
8. LLM 调用与 Prompt 管理
9.1 DeepSeek API 调用封装
基于 OpenAI SDK 封装,DeepSeek API 兼容 OpenAI 接口规范,替换 base_url + api_key 即可。
三个出入口:
| 函数 | 用途 | 特点 |
|---|---|---|
chat() |
普通对话,返回文本 | 摘要、画像、L1 降级使用 |
chat_with_tools() |
Agent 推理,返回完整对象含 tool_calls | Agent 循环使用 |
chat_stream() |
SSE 流式,逐 token yield | 前端实时展示 |
三者都经过同一组保护:熔断器前置检查 → 重试装饰器 → OpenAI SDK 调用。
为什么 temperature 有三个不同值?
- Agent 工具调用
T=0.8:需要一定的确定性来正确选工具,但又不能太死板 - 普通对话
T=1.1:角色扮演需要更多创造性 - 摘要/画像
T=0.1(在 Prompt YAML 里配置):结构化输出要求高确定性
错误分类映射:
| OpenAI 异常 | HTTP 状态码 | 用户提示 |
|---|---|---|
| AuthenticationError | 500 | “LLM 认证失败” |
| RateLimitError | 429 | “夫子正在沉思” |
| APITimeoutError | 504 | “夫子正在沉思” |
| APIError | 502 | “夫子暂时无法作答” |
9.2 Prompt 管理策略(混合式)
两类 Prompt,两种管理方式:
| 类型 | 示例 | 管理方式 | 原因 |
|---|---|---|---|
| 任务类(独立 Prompt) | 摘要生成、画像提取 | YAML 文件集中管理 | 和代码逻辑无关,非技术人员可调;含 version/temperature/max_tokens 元数据 |
| 角色类(代码内嵌 Prompt) | Skill System Prompt、MCP 工具描述 | 代码内嵌 | 和代码逻辑紧密耦合——改 Prompt 通常意味着改工具定义或行为规则,就近维护 |
PromptRegistry 设计:
# prompts/summary.yaml
version: 2
model: deepseek-chat
temperature: 0.3
max_tokens: 512
template: |
你是孔子的书童,负责总结对话内容。
已有摘要:{existing}
新增消息:{new_messages}
请将新消息的关键信息合并到摘要中...
启动时从 prompts/ 目录加载所有 YAML → 按文件名索引 → 调用方 registry.get("summary").format(...) 即可。版本号追踪变更历史,回滚有据可查。
工具列表 Prompt 动态生成:get_agent_system_prompt() 从 MCPClient 获取所有工具定义 → 自动拼接到 System Prompt 的 {TOOLS_SECTION} 占位符。加新工具 → Prompt 自动更新,不用手改。
9.3 LangChain 适配器
DeepSeekChatModel 继承 LangChain 的 BaseChatModel,实现了 _generate() 和 _stream() 两个核心方法。内部调用自家的 client.py(保留熔断+重试),对外暴露 LangChain 标准接口。
场景:如果想用 LangChain 的 LCEL(LangChain Expression Language)编排复杂链、或对接 LangSmith 做 trace,这个 adapter 就是桥梁。当前项目直接用 client.py 更简洁——零抽象开销,不需要这层适配时就不走它。
面试题
Q31: DeepSeek API 怎么调用的?和调 OpenAI 有什么区别?
基本没区别,看代码:
_client = OpenAI(
api_key=settings.deepseek_api_key,
base_url=settings.deepseek_base_url, # "https://api.deepseek.com"
timeout=30.0,
)
response = _client.chat.completions.create(
model="deepseek-chat", messages=[...],
temperature=0.8, max_tokens=1024,
tools=[...], tool_choice="auto", # Function Calling 兼容
)
DeepSeek 兼容 OpenAI SDK。唯一的注意点:Function Calling 对 JSON Schema 的 enum/oneOf 支持不如 OpenAI 完整,实践中用基础类型(string/integer/object)+ required 字段最稳妥。
Q32: 为什么 Prompt 有的放 YAML、有的嵌代码?
判断标准:改了 Prompt 需不需要同步改代码?
不需要的话(摘要、画像)→ YAML 集中管理。版本化、非技术人员可调、切换 Prompt 不用重新部署。
需要同步改的话(Skill 定义 = Prompt + allowed_tools 绑定,MCP 工具描述和 handler 绑定)→ 嵌在代码里。拆出去反而造成"改一个功能要动两个地方"的维护陷阱。
这不是"集中好还是分散好"的教条问题,是"耦合度决定管理方式"的工程判断。
Q33: 流式 SSE 怎么实现的?
async def chat_stream(messages, temperature=1.1, max_tokens=1024):
if not _llm_circuit_breaker.allow_request(): # 熔断器保护
raise HTTPException(503, ...)
stream = await _async_client.chat.completions.create(
model="deepseek-chat", messages=messages,
stream=True, # ← 关键
)
_llm_circuit_breaker.on_success()
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content # 每拿到一个token就推到前端
FastAPI 路由用 StreamingResponse(chat_stream(...), media_type="text/event-stream")。流式重试只发生在连接建立阶段——开始推 token 后不能重来(前端已渲染了一部分)。
9. 日志与可观测性
10.1 structlog 结构化日志
用 structlog 替代标准 logging,核心差异:
# 标准 logging
logger.info("token 用了 %d", 2850) # 人看得懂,机器难解析
# structlog
logger.info("llm_call", prompt_tokens=1200, completion_tokens=1650, total_tokens=2850)
# → {"event":"llm_call","prompt_tokens":1200,"total_tokens":2850,..."timestamp":"..."}
每条日志都是 JSON → ELK/Loki/Grafana 可以直接索引、搜索、出图。
10.2 trace_id 全链路追踪
HTTP 请求进来 → 中间件生成 trace_id → 存入 contextvar
→ Agent 日志自动带 trace_id
→ LLM 调用日志自动带 trace_id
→ 工具调用日志自动带 trace_id
→ 响应返回,记录耗时
核心机制:contextvars.ContextVar——Python 3.7+ 标准库,线程/协程安全。在中间件里 set_trace_id() 一次,之后所有 logger.info() 通过 _inject_context processor 自动注入 trace_id。不需要在每个函数签名里传 trace_id 参数。
为什么不用 threading.local? asyncio 中一个线程可能有多个协程,协程切换时 threading.local 不会自动切换上下文,contextvars 会。
10.3 请求日志中间件
RequestLoggingMiddleware 自动记录每个 HTTP 请求的 request_start 和 request_end(含 method、path、status、duration_ms)。不在每个路由函数里手写日志。
10.4 开发 vs 生产
开发环境 ConsoleRenderer(彩色可读),生产环境 JSONRenderer(机器可解析)。一个 dev_mode 参数切换。
面试题
Q34: trace_id 全链路怎么传递?
三步:中间件 set_trace_id()(uuid4().hex[:12] 存入 contextvar)→ structlog processor _inject_context 自动注入每条日志的 trace_id 字段 → 关键节点打点(Agent 开始/结束、LLM 调用、工具调用)。
为什么 contextvars 不用 threading.local:asyncio 一个线程多个协程,协程 A await → 切到协程 B → threading.local 被 B 覆盖 → A 恢复时 trace_id 变成 B 的 → 串号。contextvars 在 await 时自动保存/恢复,协程切换不互相污染。
Q35: 结构化日志和普通日志有什么区别?为什么不用 print?
print 是给人看的——“刚才 token 用了 2850”——人能懂但机器搜不到、聚合不了。
结构化日志每条是带 key-value 的 JSON → ELK 搜 total_tokens > 5000 → 出"token 消耗 Top 10 用户"的 dashboard → 异常检测:“过去 10 分钟错误率从 0.1% 跳到 5%,自动告警”。
一句话:结构化日志让日志从"事后翻看"变成"实时可观测"。
10. 测试策略
11.1 测试文件与覆盖范围
10 个测试文件覆盖后端全链路:
| 文件 | 覆盖范围 | 类型 |
|---|---|---|
test_auth.py |
注册/登录/JWT | 单元+集成 |
test_llm_mock.py |
LLM 调用 mock | 单元 |
test_rag.py |
向量检索 | 单元(需要模型) |
test_rag_extensions.py |
BM25/融合/Reranker | 单元(需要模型) |
test_mcp.py |
MCP 协议层 | 单元 |
test_skills.py |
Skill 注册/查询/过滤 | 单元 |
test_resilience.py |
重试/熔断/降级 | 单元 |
test_agent_graph.py |
Agent Graph 循环 | 单元(需要 LLM mock) |
test_edge_cases.py |
空输入/超长文本等边界 | 边界测试 |
test_e2e.py |
完整对话流程 | E2E |
11.2 CI 中跳过哪些测试?为什么?
pytest --ignore=test_rag.py --ignore=test_rag_extensions.py --ignore=test_agent_graph.py
CI 跳过的三类:
- RAG 测试:需要下载 ~1.3GB BGE 模型 + 2.2GB Reranker 模型,GitHub Actions runner 磁盘和时间都不够
- Agent Graph 测试:依赖 LLM mock 完整链路,环境变量和网络问题不稳定
本地跑全量,CI 跑确定性部分。这是"测试金字塔"的实践取舍——CI 保证不引入明显回归,本地做完整验证。
11.3 内存数据库 + Fixture 自动重置
TEST_DB_URL = "sqlite:///file:test_db?mode=memory&cache=shared&uri=true"
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine) # 每个测试前建表
yield
Base.metadata.drop_all(bind=engine) # 每个测试后清空
autouse=True 让每个测试自动获得干净的数据库,测试之间完全隔离。用 shared memory 模式确保多连接共享同一个内存数据库。
面试题
Q36: Agent 这种非确定性组件怎么测试?
Mock LLM 输出,测系统行为不测推理质量:
def test_agent_tool_call_then_reply(mocker):
# Mock: 第1次返回 tool_calls,第2次返回纯文本
mocker.patch("app.services.agent_graph.chat_with_tools",
side_effect=[
MockResponse(tool_calls=[{"function":{"name":"hybrid_search",
"arguments":'{"query":"仁"}'}}]),
MockResponse(content="仁者,爱人也。"),
])
result = run_agent_graph(system_prompt="你是孔子", tools=[...],
user_message="什么是仁?")
assert result["tool_calls"] == 1 # 走了1次工具调用
assert "仁者" in result["reply"] # 最终回复是第2次的文本
测试分层:Agent 循环逻辑(mock LLM → 验证条件边、迭代上限、超时 → 纯逻辑)+ 工具执行正确性(给定确定 arguments → 验证返回格式 → 纯逻辑)。不测 LLM 推理质量,测 LLM 决策后的系统行为。
Q37: 选了哪些当单元测试、哪些当 E2E?划分逻辑是什么?
单元测试:函数输入输出确定的部分——Auth 逻辑、MCP 协议解析、重试/熔断状态机、Skill 注册/过滤。这些都是"给定 A → 一定得到 B"的纯逻辑。
E2E:完整对话流程——从 /api/chat 发请求到收到响应。验证整个链路通不通。
不含糊地带:LLM 调用全部 Mock(CI 中 DeepSeek_API_KEY 是 placeholder),RAG 需要模型文件的本地跑、CI 跳过。
11. 数据库设计
12.1 ORM 模型
4 张表,SQLAlchemy ORM + SQLite:
users (用户)
├── conversations (对话)
│ └── messages (消息)
└── memory_facts (长期记忆)
| 表 | 关键字段 | 说明 |
|---|---|---|
users |
username, password_hash, profile_json | JWT 鉴权 + 用户画像存 JSON |
conversations |
user_id(FK), title, summary | 对话级摘要存 summary 字段 |
messages |
conversation_id(FK), role, content, sources | 每条消息 + 引用来源 |
memory_facts |
user_id(FK), fact_text, embedding_json | 记忆 + BGE 1024 维向量 JSON |
12.2 为什么 SQLite?
- 学习/演示项目,单机部署,零运维
check_same_thread=False支持 FastAPI 多线程- SQLAlchemy ORM 换数据库只改一行连接串:
sqlite:///...→postgresql://... - 当前数据量(消息量级几百条、记忆几十条)SQLite 完全够用
面试题
Q38: 为什么用 SQLite 而不是 PostgreSQL?设计上怎么考虑迁移的?
当前阶段 SQLite 零运维,数据量级 SQLite 完全够用。通过 SQLAlchemy ORM 抽象,所有业务代码不写原生 SQL——换 PostgreSQL 只改 database_url 配置 + 加 psycopg2 依赖。
真正需要迁移的信号:并发写入成为瓶颈(SQLite 写锁是库级)→ 需要读写分离 → 数据量到百万级。当前几百条消息量级,迁移是过度设计。
12. 部署与 CI/CD
13.1 Docker Compose 编排
三个容器:backend(Python 3.13-slim)+ frontend(Node 22-alpine 构建 → Nginx 1.27-alpine 运行)+ Nginx 反向代理。前端 Nginx 做 SPA 路由 + /api 代理到 backend。
13.2 GitHub Actions CI
两个独立 Workflow:
- backend.yml:push/PR 到 main 时触发,
paths过滤只监控backend/**变更。跑 pytest(跳过重模型测试),DEEPSEEK_API_KEY用 CI placeholder - frontend.yml:push/PR 时触发,只监控
frontend/**变更,跑npm build验证前端可构建
拆成两个 Workflow 的好处:改前端不跑后端测试,改后端不跑前端构建,互不阻塞。
面试题
Q39: CI 为什么拆成 backend + frontend 两个 Workflow?
独立变更独立验证。每次 push:只改前端 → 只跑 npm build(30s);只改后端 → 只跑 pytest(~2min)。不浪费 CI 分钟数。
paths 过滤确保改 README 不触发 CI。paths-ignore 反过来也可以——不过 paths 比 paths-ignore 更清晰:明确声明"我关心什么"而非"我不关心什么"。
Q40: 生产部署还需要考虑什么?
环境变量管理(生产 API Key 不进 Git,用 GitHub Secrets 或 K8s Secret)、健康检查端点(K8s liveness/readiness probe)、优雅关闭(SIGTERM 处理后等当前请求完成再退出)、日志持久化(容器日志 → Loki/ELK)、SSL 证书(Nginx 或 Cert-Manager)。
13. 开放题
Q41: 这个项目最大的技术挑战是什么?
协调 Agent 的不确定性。LLM 可能不调工具、乱调工具、调了 5 次还不满意、返回非 JSON 格式的 tool_call。每一步都不确定,但用户体验要求稳定。
解决思路不是"让 LLM 更听话"——那是训练的事。而是工程防御:超时兜底(120s)、迭代上限(5 次)、参数校验(类型检查)、错误返回(不抛异常返回文本)、三级降级。每一层都是对 LLM 不确定性的防御。
Q42: 如果重新设计,会改什么?
- 评估优先:先建评估数据集再调 RAG 参数,不凭感觉调
- 流式降级:L1 和 L2 也支持 SSE 流式,现在只有 L0 有
- 可观测性:Agent 每一步推理+工具调用的 trace 可视化
- 配置热加载:Skill 和 Prompt 支持 YAML 动态加载,不重启生效
Q43: 部署到生产环境还缺什么?
- 可观测性:OpenTelemetry tracing + Prometheus metrics + Grafana dashboard
- 持久化限流:内存 → Redis,支持多实例
- API 计费:token 用量 × 单价 × 用户
- A/B 测试框架:不同 Prompt/RAG 参数的对比实验
- 缓存层:相似问题缓存回答,减少重复 LLM 调用
- 异步记忆存储:BGE 编码改为后台队列,不阻塞请求
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)