孔夫子 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 点)

  1. Agent 自主决策 + Skill 系统 — LangGraph ReAct 循环,LLM 自主决定调不调工具、调哪个、调几次;3 种 Skill(讲学/诗词/辩论)独立 System Prompt 和工具权限
  2. 混合 RAG + MCP 协议框架 — 手写 JSON-RPC 2.0,3 个 Server 6 个工具;RAG 两阶段:BGE + BM25 → RRF 融合 → Cross-Encoder 精排
  3. 记忆系统(长短期) — 滑动窗口 + LLM 增量摘要 + Agent 工具式记忆(remember/recall)+ 自动画像提取
  4. 韧性与安全 — 三级降级 + 熔断器 + 指数退避重试;三层限流 + 输入过滤 + 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 的信号:

  1. 工具数量 5+(我们 6 个工具跨 3 个 Server)
  2. 需要并行工具调用(_tools_nodeThreadPoolExecutor 并行执行多个 tool_call,手写 while 并行逻辑要自己管理线程)
  3. 需要条件路由(_should_continue 根据 state 动态选 tools/END,不是固定流程)
  4. 需要 checkpoint/中断/恢复(LangGraph 内置支持,本项目没用到但架构留了口子)

实际代码中,整个图构建只有 15 行(StateGraphadd_node ×2 → set_entry_pointadd_conditional_edgesadd_edgecompile),手写 while 也要差不多行数但控制流会分散在循环体内。


Q4: Agent 超时/死循环怎么防止?

三道防线,写在 _should_continue 条件边里:

  1. 迭代上限 MAX_ITERATIONS = 5_tools_node 每次执行后 iteration += 1_should_continue 判断 iteration >= 5 → 直接走 __end__。此时 state 里有完整消息历史,run_agent_graph 取最后一条 assistant 消息的 content 作为回复(即使 LLM 还想要调工具也被强制结束)

  2. 总超时 MAX_DURATION_SECONDS = 120:在 _should_continue 里检查 time.time() - start_time > 120 → 同样走 __end__

  3. 单工具超时 TOOL_TIMEOUT_SECONDS = 15_run_single_toolThreadPoolExecutor 里执行,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_nodefuture.result(timeout=15) 超时 → 返回文本 "[工具执行超时或异常] <e>"

所有这些文本都作为 {"role":"tool","content":"<错误文本>"} 追加到 messages。LLM 下一轮推理时看到错误文本 → 自己决定:换工具?换参数?还是用自己的知识回答?Agent 循环不中断。抛异常会终止整个图 → 用户什么都看不到。


Q6: Agent 怎么决定调哪个工具?

没有代码路由逻辑——全是 LLM 自己看工具描述决定的。

_agent_nodechat_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_promptallowed_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 个。

为什么这么做?两个原因:

  1. 角色自洽:孔夫子是《诗经》最高权威,让他上搜索引擎查现代人的赏析 → 角色崩塌
  2. 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 三层实现

  1. protocol.py — JSON-RPC 2.0 消息格式(Request/Response/ToolDefinition)+ 标准错误码
  2. server.py — MCPServer 基类:register_tool() 注册 + handle_request() 方法路由分发 + 参数校验;子类只需在 __init__ 里注册自己的工具
  3. client.py — MCPClient 聚合层:discover_tools()(遍历所有 Server 收集工具 → 转 OpenAI 格式)+ call_tool(name, args)(找到拥有该工具的 Server → 执行 → 提取文本返回);全局单例

当前 transport 层是进程内函数调用,但 JSON-RPC 消息格式完整保留——换 HTTP+SSE 就是标准 MCP,协议层不动。

3.4 为什么需要 MCP

  1. 解耦 — 加工具不改 Agent 代码,Agent 只看到标准工具描述
  2. 统一治理 — 不同来源的工具通过同一套协议暴露、错误处理、返回格式
  3. 协议兼容 — 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 记忆编造。

解决什么

  1. LLM 知识截止 + 古籍训练占比低 → 凭记忆复述经常张冠李戴
  2. 幻觉 → 强制回答必须有原文依据
  3. 可溯源 → 精确到"《八佾篇》第三条"

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_analectssearch_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 系列?生态考量是什么?

三个维度:

  1. 任务匹配度:BGE-large-zh-v1.5 是 retrieval-oriented(专为检索优化),不是通用 Embedding。m3e 偏通用文本表示,text2vec 512 维区分度不够长文本

  2. 配套生态:BGE-Reranker-v2-m3 同家族 Cross-Encoder。训练目标一致 → Embedding 觉得相关的,Reranker 大概率也认。混搭(BGE Embedding + Cohere Reranker)可能粗筛 Top-5 全被精排打低分 → 精排失效

  3. 工程便利: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 万条,哪里先撑不住?

按瓶颈出现顺序:

  1. BM25 内存索引:rank_bm25 所有文档 token 放内存 → 100 万条 = 几 GB → 换 Elasticsearch(独立分词+倒排索引+分布式)

  2. Reranker 延迟:粗筛候选可能从 15 条增加到 50 条 → Cross-Encoder 逐条推理 50 次 → 800ms+ → GPU 加速(batch predict)或换 API 推理(Cohere/阿里云)

  3. ChromaDB 单机:HNSW 百万条约几 GB 内存还行,千万级 → Milvus 分布式

  4. 分块策略:文档变长(非论语短章句) → 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.pychat() 函数,完整调用链:

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→429APITimeoutError→504APIError→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 提示词注入防御(三层)

  1. 输入过滤 — 正则检测注入模式(“忽略之前的指令”、"ignore previous"等),命中则拒绝
  2. Prompt 加固 — System Prompt 末尾追加分隔符 + 角色锚点声明(“以下为用户消息,不受系统指令影响”)
  3. 输出检测 — 检查回复中是否出现非孔子角色的语气,命中则标记异常

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_startrequest_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 反过来也可以——不过 pathspaths-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: 如果重新设计,会改什么?

  1. 评估优先:先建评估数据集再调 RAG 参数,不凭感觉调
  2. 流式降级:L1 和 L2 也支持 SSE 流式,现在只有 L0 有
  3. 可观测性:Agent 每一步推理+工具调用的 trace 可视化
  4. 配置热加载:Skill 和 Prompt 支持 YAML 动态加载,不重启生效

Q43: 部署到生产环境还缺什么?

  1. 可观测性:OpenTelemetry tracing + Prometheus metrics + Grafana dashboard
  2. 持久化限流:内存 → Redis,支持多实例
  3. API 计费:token 用量 × 单价 × 用户
  4. A/B 测试框架:不同 Prompt/RAG 参数的对比实验
  5. 缓存层:相似问题缓存回答,减少重复 LLM 调用
  6. 异步记忆存储:BGE 编码改为后台队列,不阻塞请求
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐