avatar

Neo·元

算法的尽头,认知的倒影

  • 首页
  • 三千问道
  • 万法归宗
  • 诗酒田园
  • 关于Neo·元
主页 浅谈 LangGraph 智能体演进:从 Ollama 本地模型到 DeepSeek-R1 API 的踩坑与架构重构
文章

浅谈 LangGraph 智能体演进:从 Ollama 本地模型到 DeepSeek-R1 API 的踩坑与架构重构

发表于 最近 更新于 最近
作者 Neo
22~28 分钟 阅读

前言

在本地部署大模型开发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将整套风控流程解耦为事件驱动模式,向高并发生产架构继续演进。

万法归宗
DeepSeek LangGraph AI
许可协议:  CC BY-NC 4.0
分享
本文同步发布于个人博客 Neo·元,转载请注明出处。

相关文章

9月 3, 2026

浅谈 LangGraph 智能体演进:从 Ollama 本地模型到 DeepSeek-R1 API 的踩坑与架构重构

前言 在本地部署大模型开发Agent项目,基于现有硬件环境和调试成本考量,优先使用的是基于Ollama部署的3B/7B模型。但当业务进入“海关风控与跨境物流”这种对指令遵循、工具调用以及人工干预有绝对硬红线的真实场景时,小模型的劣势会被无限放大。 本文记录了我将一个 LangGraph Agent

9月 1, 2026

浅谈 HITL 与 Checkpointer

前言 本地跑通了 Multi-Agent 之后,我一直在想,企业级 AI 应用的两个关键技术还没真正用上:一个是 HITL(Human-in-the-Loop,人工介入),一个是 Checkpointer(状态持久化与回滚)。正好最近在研究跨境物流报关场景——这个领域因为涉及海关监管,人工审核是硬性

8月 29, 2026

聊一下 LangGraph 的流式打印

在把项目从 LangChain 升级到 LangGraph 的过程中,最让人头疼的既不是节点状态(State)的定义,也不是 Redis Checkpointer 的持久化挂载,而是流式打印(Streaming Output)。 明明代码写得毫无 Bug,逻辑边和 HITL 人工中断也都流畅运行,但

下一篇

浅谈 HITL 与 Checkpointer

上一篇

最近更新

  • 浅谈 LangGraph 智能体演进:从 Ollama 本地模型到 DeepSeek-R1 API 的踩坑与架构重构
  • 浅谈 HITL 与 Checkpointer
  • 聊一下 LangGraph 的流式打印
  • 调试 Cursor 与 Claude Code
  • 浅谈升级Multi-Agent

热门标签

Tools DeepSeek AI LangChain RAG LangGraph

©2026 All Rights Reserved Neo 鲁ICP备2026037083号