LangGraph 内容生产流水线 — 知识点 & 面试题
以一个「内容生产流水线」项目为例,覆盖 LangGraph 机制、Multi-Agent 设计、LLM 工程、测试策略、Python 实践五个维度,均结合项目实际代码,面试时可直接讲解。
一、LangGraph 核心概念
知识点
1. StateGraph — 有状态的图
图的核心是一个共享状态(ContentState),每个节点接收完整状态、返回更新后的状态。LangGraph 自动合并差量,不需要手动维护全局变量。
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 特性:
# 节点内调用 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 — 会话隔离
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_edge 和 add_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 | 动态分工、任务不确定 |
| Swarm | Agent 间平等协作 | 并行探索、大规模任务 |
| 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)
用环境变量 PROVIDER 和 MODEL 解耦 LLM 选型与业务代码:
- 业务代码只调用
get_llm(),不关心底层是哪家模型 - 切换模型只需改
.env,无需改任何 Agent 代码 - 使用懒导入(import 放在 if 分支内)避免在不使用某 provider 时要求安装其依赖
2. Unicode 代理字符问题
网页内容可能含损坏的 emoji(代理字符对,如 \ud83d),Anthropic SDK 能处理,但 DeepSeek 基于 OpenAI SDK 做 JSON 序列化时会抛 UnicodeEncodeError。解决:
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.py | TypedDict 字段存在性和类型 | 无 mock |
test_tools.py | search_web 格式化逻辑 | patch("tools.search.TavilySearch") |
test_agents.py | 各 Agent 更新正确字段 | patch("agents.*.get_llm") |
test_graph.py | 路由函数逻辑 | 直接调用 route_after_review |
test_integration.py | 完整流水线 interrupt/resume | build_graph() + Command(resume=...) |
patch 目标的正确位置:要 patch 被测代码导入后的名字,而非原始定义位置。agents/research.py 里 from 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} 这种部分更新字典,效率更高。本项目用展开语法是为了教学清晰度。