浅谈 LangGraph 智能体演进:从 Ollama 本地模型到 DeepSeek-R1 API 的踩坑与架构重构
前言
在本地部署大模型开发Agent项目,基于现有硬件环境和调试成本考量,优先使用的是基于Ollama部署的3B/7B模型。但当业务进入“海关风控与跨境物流”这种对指令遵循、工具调用以及人工干预有绝对硬红线的真实场景时,小模型的劣势会被无限放大。 本文记录了我将一个 LangGraph Agent 工作流从本地 Ollama+Qwen2.5:3B全面升级到 DeepSeek 线上 API 的全过程。重点剖析切换过程中暴露出的LangGraph 运行时上下文断层、HITL 恢复重跑机制、以及带 Thinking状态模型在Checkpoint持久化中的底层协议失效与破局。
前言
1. 痛点重现:为什么 3B 小模型做不了生产级 Agent?
在最初的架构中,我使用 Ollama 挂载Qwen2.5:3B作为 LangGraph 工作流的推理引擎。在处理“我要申报 25Wh 电池”这类风控敏感请求时,哪怕我在SystemPrompt 里写入了极致严格的红线规则:
Plaintext
🔴【绝对红线规则 - 违者失效】:
1. 当用户提到任何物品、电池、商品时,【绝对禁止】主观回答或编造风控结果!
2. 你【必须、且只能】通过调用 `risk_check_tool` 获取判定。
但在 Langfuse 的链路监控中,依然频繁出现幻觉:
Plaintext
_start_ ➔ agent ➔ _end_ (绕过了 tools 节点,模型直接编造了一段专业风控文案)
架构洞察:小参数模型做工具调用漂移是必然现象。这不是 Prompt Engineering 能够修复的,而是参数量根本无法承载长上下文下的复杂指令遵循。要实现生产级风控 Agent,必须切换到高智商的线上 API。
2. 基础坑点:从模型幻觉到工具强制绑定
将 LLM 切换为 DeepSeek 后,依然遇到了首轮不触发 Tool Calls 的问题。排查后发现了框架调用的两个隐蔽细节:
2.1 隐式重复绑定
Python
# ❌ 错误做法:bind_tools 返回的是新的 RunnableBinding,重复调用会导致配置覆盖或丢失
self.llm = ChatOpenAI(...).bind_tools(ALL_TOOLS)
self.llm_with_tools = self.llm.bind_tools(ALL_TOOLS)
# ✅ 正确做法:一步到位,并引入 tool_choice 强约束
self.llm_with_tools = self.llm.bind_tools(ALL_TOOLS, tool_choice="any")
在海关风控首轮,通过tool_choice剥夺模型直接文本回答的选项,强行将其收敛至 Tool Calling 链路。
3. LangGraph HITL 中断机制与“上下文气泡”破裂
在引入 interrupt 实现人机交互审批时,遇到抛错: RuntimeError: Called get_config outside of a runnable context
3.1 根因:RunnableConfig 是运行时上下文信封
LangGraph 的 interrupt() 或 astream_events() 并非单纯依赖显式参数,而是利用 Python 的 contextvars 隐式抓取外层上下文。这个“信封”里包裹着:
thread_id:用于 Redis Checkpointer 的状态读写。
checkpoint_id:当前图节点的执行快照。
callbacks:Langfuse 链路追踪与流式 Token 收集。
如果在自定义节点中丢弃了 config,上下文气泡即刻破裂,interrupt() 无法写入 Redis 状态机。
3.2 解决方案:显式透传与手动上下文绑定
Python
from langchain_core.runnables.config import var_child_runnable_config
async def _human_approval_node(self, state: AgentState, config: RunnableConfig = None) -> Dict[str, Any]:
# 手动将 config 注入当前 Async Task 的 Runnable 上下文
token = var_child_runnable_config.set(config) if config else None
try:
approval_payload = interrupt({
"type": "RISK_APPROVAL_REQUIRED",
"message": "商品触发高风险风控规则,必须进行人工审核!"
})
finally:
if token:
var_child_runnable_config.reset(token) # 恢复上下文
4. 幂等性陷阱:WebSocket 在 HITL 唤醒时的重复推送
在排查 B 端客服工作台时,发现每次人工审批后,系统都会收到两条重复的风险工单推送。
根因:LangGraph 的状态恢复机制是从触发 interrupt 的节点起点重新执行。如果把 WebSocket 广播写在 _human_approval_node 内部,挂起前会推一次,唤醒恢复时会再跑一次。
破局:将广播逻辑前移至只跑一次的 _tools_node 中,实现图节点副作用的幂等性。
5. 核心破局:Reasoning 状态在 Checkpoint 持久化中的丢失与 API 400 校验
这是整个升级过程中最棘手的硬核大坑。在测试“状态回滚”或“HITL 审批恢复”时,DeepSeek 接口频繁暴毙: HTTP 400: The reasoning_content in the thinking mode must be passed back to the API.
5.1 协议断层分析
DeepSeek-R1 这类带 Thinking 模式的模型,要求历史对话中的 assistant 消息必须完整带回 reasoning_content 字段。
然而LangGraph 的默认 Checkpointer 在将状态序列化写入 Redis 时,只会提取标准的 content 和 tool_calls,additional_kwargs 里的 reasoning_content 被 LangChain 原生序列化器静默丢弃了。
Plaintext
[ DeepSeek API ] ──(带 reasoning_content)──> [ LangGraph AIMessage ]
│
(Redis Checkpointer 序列化)
▼
[ reasoning_content 被抹除 ]
│
(HITL 恢复 / 回滚历史)
▼
[ DeepSeek API ] <──(缺失 reasoning_content)── [ 发送 HTTP 报文 (触发 400 报错) ]
5.2 单元测试抓包断言
为了不浪费线上 API 额度,我编写了一个本地模拟测试脚本,验证 LangChain 消息对象转 Payload 的真实过程:
Python
async def test_thinking_patch_payload():
logger.info("🧪 开始本地 Payload 结构断言测试...")
mock_raw_messages = [
SystemMessage(content="你是海关风控助手"),
AIMessage(content="", tool_calls=[{"name": "risk_check_tool", "args": {"product": "乙醇"}, "id": "call_123"}])
]
# 模拟转换过程
payload_dict = prepare_deepseek_payload(mock_raw_messages)
# 断言强校验
assert "reasoning_content" in payload_dict[1], "❌ Fatal: Payload 中仍然缺失 reasoning_content!"
logger.info("✅ 【完美通过】Payload 已成功注入 reasoning_content!")
5.3 破局架构:绕过 LangChain 序列化,手工接管 HTTP Payload
既然 LangChain 的默认序列化器会抹除非标字段,最稳妥的架构选择就是显式接管消息转换层。
编写 app/utils/deepseek_helper.py 兜底转译器:
Python
import json
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage, BaseMessage
def prepare_deepseek_payload(messages: list[BaseMessage]) -> list[dict]:
"""
将 LangChain 历史消息列表强转为 DeepSeek API 原生 Protocol Dict,
强行补齐 assistant 消息的 reasoning_content 保底字段。
"""
payload_messages = []
for msg in messages:
if isinstance(msg, AIMessage):
item = {"role": "assistant", "content": msg.content or ""}
# 三级提取/保底逻辑
reasoning = (
(msg.additional_kwargs or {}).get("reasoning_content")
or (msg.response_metadata or {}).get("reasoning_content")
or "Thought process restored for API compliance."
)
item["reasoning_content"] = str(reasoning)
if getattr(msg, "tool_calls", None):
item["tool_calls"] = [{
"id": tc.get("id"),
"type": "function",
"function": {
"name": tc.get("name"),
"arguments": json.dumps(tc.get("args", {}), ensure_ascii=False) if isinstance(tc.get("args"), dict) else str(tc.get("args"))
}
} for tc in msg.tool_calls]
payload_messages.append(item)
elif isinstance(msg, HumanMessage):
payload_messages.append({"role": "user", "content": msg.content})
elif isinstance(msg, SystemMessage):
payload_messages.append({"role": "system", "content": msg.content})
elif isinstance(msg, ToolMessage):
payload_messages.append({"role": "tool", "content": msg.content, "tool_call_id": msg.tool_call_id})
return payload_messages
在 _agent_node 中直接配合原生 OpenAi Client 执行:
Python
# 1. 手动转译带 reasoning_content 的 Payload
api_messages = prepare_deepseek_payload(cleaned_messages)
# 2. 原生客户端调用,完美避开框架擦除
response_raw = await self.raw_client.chat.completions.create(
model=os.getenv("DEEPSEEK_MODEL"),
messages=api_messages,
tools=openai_tools,
extra_body={"thinking": {"type": "enabled"}},
temperature=0.1
)
6. 避坑指南:Redis 脏状态与 Local Import 工程规范
6.1 Redis Checkpoint 脏数据清理
当完成了代码重构后,如果重启 FastAPI 依然报错,大概率是 Redis 里存了旧代码生成的旧结构 Checkpoint。LangGraph 恢复会话时反序列化了错误的历史状态。 直接清理 Redis 对应 Session 缓存或执行 flushall 即可解决。
6.2 关于局部导入的工程权衡
在优化代码时,我在 _agent_node 内部编写了:
Python
from app.utils.deepseek_helper import prepare_deepseek_payload
按照 PEP 8 规范,import 应当统一放在文件顶部。但当 agent_service.py 与 thinking_patch_service.py 存在依赖交织时,在函数内部进行延迟导入是避开 Python 循环依赖最务实、最标准的工程手段,无需产生心理负担。
7. 整理
通过这次升级,系统成功跑通了“DeepSeek 工具精准调用、风控触发 HITL、Redis 状态挂起、人工审批回复 、上下文还原恢复 、最终结果输出”的全链路。下一步,准备引入 Message Queue将整套风控流程解耦为事件驱动模式,向高并发生产架构继续演进。