Skip to content

LangGraph 内容生产流水线 — 知识点 & 面试题

以一个「内容生产流水线」项目为例,覆盖 LangGraph 机制、Multi-Agent 设计、LLM 工程、测试策略、Python 实践五个维度,均结合项目实际代码,面试时可直接讲解。

一、LangGraph 核心概念

知识点

1. StateGraph — 有状态的图

图的核心是一个共享状态(ContentState),每个节点接收完整状态、返回更新后的状态。LangGraph 自动合并差量,不需要手动维护全局变量。

python
StateGraph(ContentState)
  → add_node("name", fn)
  → add_edge / add_conditional_edges
compile(checkpointer=...)

2. 节点 (Node) vs 边 (Edge)

类型API作用
固定边add_edge(A, B)A 完成后必定到 B
条件边add_conditional_edges(A, fn, map)fn 返回字符串,映射到下一节点
入口set_entry_point("node")图从哪个节点开始
终止END特殊标记,图执行完毕

3. interrupt / resume — 人工介入机制

这是本项目最核心的 LangGraph 特性:

python
# 节点内调用 interrupt() → 图暂停,返回给调用方
feedback = interrupt("提示文本")   # 这行之后的代码暂停执行

# 外部恢复执行,resume 的值成为 interrupt() 的返回值
result = graph.invoke(Command(resume=user_input), config)

interrupt 工作原理interrupt() 内部抛出一个特殊异常,LangGraph 捕获后把当前状态序列化到 checkpointer(本项目用 MemorySaver),然后把控制权交还给调用方。下次 invoke 时,从断点继续,interrupt() 的返回值就是 Command(resume=...) 里传入的值。

4. MemorySaver — 必须有 checkpointer

没有 checkpointer,interrupt() 无法保存状态,resume 就无从谈起。MemorySaver 把状态存在内存中,适合开发和测试;生产环境可换 SqliteSaver / PostgresSaver

5. thread_id — 会话隔离

python
config = {"configurable": {"thread_id": "my_session"}}

相同 thread_id 的多次 invoke 共享同一份状态(支持断点续跑);不同 thread_id 完全隔离。测试中每个 test case 用不同的 thread_id 就是为了防止状态污染。

面试题

Q1:LangGraph 的 interrupt() 和普通的 input() 有什么区别?

input() 会阻塞当前进程,整个程序挂起等待输入,无法在 Web/API 场景中使用。interrupt() 则是"暂停图"而不阻塞线程——它把当前执行状态序列化到 checkpointer,然后把控制权返回给调用方(比如 FastAPI handler),调用方可以立刻响应用户,等用户提交反馈后再发起新的 invoke(Command(resume=...)) 恢复执行。这使得异步 Web 交互成为可能。

Q2:为什么 MemorySaver 要求 state 中的所有值都可序列化?

MemorySaver 使用 Python 的序列化机制(实际是深拷贝)持久化状态快照。如果某个字段是 MagicMock 或 lambda,序列化会失败。这就是集成测试中 _mock_llm() 要把 response.content 设为真实字符串而非 MagicMock 的原因。

Q3:add_edgeadd_conditional_edges 分别在什么场景下用?

add_edge 用于确定性流转(A 完成后一定去 B,本项目中所有 Agent → human_review 都是固定边)。add_conditional_edges 用于根据状态动态决策(human_review 之后根据反馈是通过、重跑还是结束,由 route_after_review 函数决定)。

Q4:同一个图实例可以并发处理多个用户请求吗?

可以,通过不同的 thread_id 隔离。每个用户对应一个 thread_id,状态完全独立。图对象本身是无状态的,状态全部存在 checkpointer 里。

二、状态管理设计

知识点

TypedDict 共享状态

本项目用 TypedDict 而非 dataclass 或 BaseModel,原因:

  • LangGraph 要求状态是字典(底层做 dict 合并)
  • TypedDict 提供类型注解但运行时仍是普通字典,零开销
  • 每个节点返回 {**state, "field": new_value},模式是不可变更新(不修改原 state)

current_step 的设计决策

current_step 记录的是刚完成的步骤,而不是下一步。这使得 route_after_review 可以直接用 STEP_ORDER.index(current_step) 找到当前位置,然后 +1 得到下一步,逻辑清晰无歧义。

面试题

Q5:LangGraph 中 state 更新是 merge 还是 replace?

默认是按 key merge:节点返回 {"field": value} 时,只更新该 key,其他 key 保持不变。但如果节点返回整个 state 字典({**state, "field": value}),效果相同。如果需要 list 追加而非替换,可以在定义 StateGraph 时给字段指定 Annotated[list, operator.add] 类型,LangGraph 会自动做 append 合并。

Q6:revision_count 的上限逻辑为什么放在路由函数而不是 Agent 节点里?

关注点分离。Agent 节点只负责生成内容,不知道自己是第几次重跑。路由函数(route_after_review)掌握流程控制的完整上下文(反馈内容 + 重跑次数),在这里判断是否强制推进,逻辑集中、易于测试和修改。

三、Multi-Agent 架构模式

知识点

本项目是线性 Pipeline(流水线)模式,不是 Supervisor 或 Swarm:

research → human_review → outline → human_review → writer → ... → END

每个 Agent 职责单一,通过共享 state 传递中间产物,而非直接通信。这是最常见的 Multi-Agent 入门模式。

常见 Multi-Agent 模式对比:

模式结构适合场景
Pipeline(本项目)A→B→C 串行步骤固定、依赖明确
Supervisor主 Agent 分发任务给子 Agent动态分工、任务不确定
SwarmAgent 间平等协作并行探索、大规模任务
ReAct单 Agent 循环 Think-Act工具调用推理

面试题

Q7:什么时候用 Pipeline,什么时候用 Supervisor?

Pipeline 适合步骤固定且有明确先后依赖的场景(调研→大纲→写作,必须按顺序)。Supervisor 适合任务边界模糊、子任务可以并行或动态分配的场景(比如"帮我分析这个市场",Supervisor 可能同时派出竞品分析、用户调研、数据收集三个 Agent)。本项目内容生产流程是线性的,Pipeline 更简单直接。

Q8:Multi-Agent 系统中 state 应该设计得多大?

尽量精简。只存各 Agent 必须共享的字段,避免把临时变量也放进 state(比如搜索结果可以在 research_agent 内部处理完再提炼成 research 字段写入 state,原始搜索列表不需要进 state)。state 越大,序列化开销越高,调试也越难。

四、LLM 集成与工程实践

知识点

1. 多 Provider 动态路由(config.py)

用环境变量 PROVIDERMODEL 解耦 LLM 选型与业务代码:

  • 业务代码只调用 get_llm(),不关心底层是哪家模型
  • 切换模型只需改 .env,无需改任何 Agent 代码
  • 使用懒导入(import 放在 if 分支内)避免在不使用某 provider 时要求安装其依赖

2. Unicode 代理字符问题

网页内容可能含损坏的 emoji(代理字符对,如 \ud83d),Anthropic SDK 能处理,但 DeepSeek 基于 OpenAI SDK 做 JSON 序列化时会抛 UnicodeEncodeError。解决:

python
text.encode("utf-8", errors="ignore").decode("utf-8")

3. Tavily API 迁移

旧版 TavilySearchResults 已弃用,新版 TavilySearch

  • 输入:invoke({"query": "..."})(字典)
  • 输出:{"results": [{content, url, ...}]}(嵌套结构)

面试题

Q9:为什么用懒导入(import 放在函数内)而不是文件顶部 import?

顶部 import 在模块加载时立即执行。如果用户没有安装 langchain-deepseek 但也不打算用 DeepSeek,顶部 import langchain_deepseek 会立刻报错,即使用户根本不调用 DeepSeek 的代码。懒导入把 import 推迟到真正需要时,配合友好的错误提示("请运行 uv sync --extra deepseek"),用户体验更好。

Q10:.env 文件中行内注释(KEY=value # 注释)会有什么问题?

python-dotenv 默认不解析行内注释,# 注释 部分会成为值的一部分。本项目曾因此把模型名读成 deepseek-v4-pro#deepseek-reasoner,导致 API 返回 "model not found" 错误。正确做法是注释单独成行(# 注释 放在上一行),或确保值后面没有多余字符。

五、测试策略

知识点

本项目 16 个测试,全部 mock,不发真实 API 请求:

测试文件测试内容关键技术
test_state.pyTypedDict 字段存在性和类型无 mock
test_tools.pysearch_web 格式化逻辑patch("tools.search.TavilySearch")
test_agents.py各 Agent 更新正确字段patch("agents.*.get_llm")
test_graph.py路由函数逻辑直接调用 route_after_review
test_integration.py完整流水线 interrupt/resumebuild_graph() + Command(resume=...)

patch 目标的正确位置:要 patch 被测代码导入后的名字,而非原始定义位置。agents/research.pyfrom config import get_llm,所以 patch agents.research.get_llm,不是 config.get_llm

每次测试用 build_graph():MemorySaver 是有状态的,如果多个测试共用同一个图实例,前一个测试的状态会污染后一个。每次 build_graph() 创建全新的 MemorySaver 实例,彻底隔离。

面试题

Q11:patch("agents.research.get_llm")patch("config.get_llm") 有什么区别?为什么要 patch 前者?

Python 的 patch 替换的是目标模块命名空间中的引用。research.py 执行 from config import get_llm 后,get_llm 这个名字就绑定在 agents.research 模块的命名空间里了。patch config.get_llm 只修改了 config 模块里的引用,但 agents.research 已经持有自己的引用副本,不受影响。所以必须 patch agents.research.get_llm

Q12:集成测试中为什么 _mock_llm 的返回值不能是 MagicMock 对象的 .content

MemorySaver 序列化 state 时,要求所有值都可深拷贝。MagicMock 对象无法被正常序列化,导致 interrupt() 保存状态时抛异常。因此 response.content 必须是真实的字符串(response.content = "核心观点:AI 改变教育"),而不是默认的 MagicMock 属性。

Q13:测试 test_full_pipeline_approve_all 中为什么循环 5 次 Command(resume="approve"),而不是 1 次?

每次 invoke 只会执行到下一个 interrupt() 就暂停。流水线有 5 个审核点(research/outline/writer/seo/adapter),每次 approve 推进一步,所以需要 5 次才能跑完整个流水线。这正是验证 interrupt/resume 链路正确的核心逻辑。

六、Python 工程实践

Q14:TypedDict vs dataclass vs Pydantic BaseModel,各自的适用场景?

  • TypedDict:运行时是普通字典,类型注解仅供静态检查器使用,序列化零开销,适合 LangGraph state 这种"必须是字典"的场景。
  • dataclass:有真实的类实例,支持默认值、__post_init__,适合内部数据模型。
  • Pydantic BaseModel:带运行时类型验证,适合 API 入参/出参校验,FastAPI 默认使用。

Q15:{**state, "field": new_value} 这种更新方式有什么优缺点?

优点:不可变更新,原 state 不被修改,便于调试和追溯(每个节点有明确的输入输出快照)。缺点:每次都复制整个字典,state 很大时有内存开销。LangGraph 内部实际只做差量合并,所以也可以只返回 {"field": new_value} 这种部分更新字典,效率更高。本项目用展开语法是为了教学清晰度。