从 Dify 到 LangGraph:为什么开发者还要写代码

0 阅读14分钟

从 Dify 到 LangGraph:为什么开发者还要写代码

上一篇《dify+elk-mcp 实现 AIOps 对话式获取日志数据》用 Dify 拖拖拽拽搭了一套对话式日志查询方案,证明了低代码在 AIOps 场景里确实能跑通。

但这篇文章要换一个视角:当你把这套方案从 demo 推到生产,或者当你和一群开发者坐在一起讨论架构时,为什么大多数人最终还是会回到 LangChain、LangGraph,而不是继续拖拖拽拽?

Dify 在企业里用得不少,因为它降低了非开发者的参与门槛。可一旦进入工程化阶段,开发者会更倾向代码。这不是偏见,而是低代码模式本身有三个隐性天花板决定的。

一、回顾:那篇 Dify+ELK-MCP 方案到底做了什么?

原文的方案流程很清晰:

flowchart TD
    A[用户输入自然语言查询] --> B[Dify-LLM DeepSeek-V3]
    B --> C[参数提取器]
    C --> D{参数提取成功?}
    D -->|否| F[返回引导提示]
    F --> A
    D -->|是| E[API 参数构建器]
    E --> G[生成标准化 API 请求体]
    G --> H[HTTP 调用 ELK-MCP]
    H --> I{API 请求成功?}
    I -->|否| K[失败重试 最多3次]
    K -->|重试成功| J[接收日志数据]
    K -->|重试失败| L[返回失败提示]
    I -->|是| J
    J --> M[LLM 生成分析报告]
    M --> N{简单/深度?}
    N -->|简单| O[返回关键日志]
    N -->|深度| P[返回结构化报告]

它做了三件关键的事:

  1. 自然语言转结构化参数:用 DeepSeek-V3 从“查 kf1 租户 web-scrm 最近 30 分钟错误日志”里提取 tenant_idserviceslog_levelstime_range_minutes 等字段。
  2. API 参数构建:用 Python 代码节点把结构化参数拼成 ELK-MCP 的请求体。
  3. 结果分析:再用 LLM 对返回的日志做 SRE 视角的深度分析,输出报告。

这个流程在 Dify 里跑得很漂亮,但它隐藏了一个前提:流程是固定的、异常是可配置的、人是守在屏幕前的。 当这些前提松动时,代码框架的优势就出来了。

二、Dify 在企业里为什么能火?

在讨论“为什么开发者不用 Dify”之前,得先说清楚它为什么在企业里有市场:

  • 快速验证:2 小时搭出 demo,不用搭后端、不用写前端。
  • Prompt 管理可视化:系统提示词、变量、模型参数都能在一个页面里调。
  • 运营人员能参与:业务方可以自己改提示词、加分支,不需要等排期。
  • 内置 LLMOps:调用日志、token 统计、模型切换都现成的。

这些能力让 Dify 非常适合原型、内部工具、非核心流程。但一旦系统进入生产环境,开始对接 CI/CD、监控告警、权限审计、故障演练,开发者就会发现:低代码省下的时间,会在维护阶段加倍还回来。

三、Dify 的三个隐性天花板

我把低代码模式在生产环境里的限制,总结成三张天花板。每张天花板都对应一个必须从 Dify 走向代码的理由。

天花板Dify 的表现带来的问题代码框架怎么破
版本控制与可测试性DSL diff 可读性差;代码节点里的 Python 没法单独跑单元测试无法做 code review、无法写 pytest、无法回滚LangChain:把每个节点拆成可测试的 Python 函数和类
执行确定性与控制力循环、分支、重试被包在节点内部,黑盒流程一复杂就看不清数据怎么流,调试靠点执行日志LangGraph:State / Node / Edge 显式化,每一步都可控
长任务与异常恢复节点级错误分支,崩溃后难恢复;长任务中断要重跑分页查询到一半挂了,只能从头再来;人工审批靠 UI 阻塞LangGraph:checkpoint 断点续跑、interrupt 人机审批、time travel 回溯

这三张天花板不是 Dify 的 bug,而是低代码模式的固有限制。低代码用“抽象”换“速度”,代码用“显式”换“控制”。当你的流程从“能用”变成“必须稳定运行”时,抽象就开始漏底。

flowchart LR
    subgraph Dify["Dify 低代码"]
        D1[快速验证]
        D2[可视化]
        D3[运营参与]
    end

    subgraph Ceiling["生产化天花板"]
        C1[版本控制]
        C2[确定性]
        C3[异常恢复]
    end

    subgraph Code["代码框架"]
        L1[LangChain]
        L2[LangGraph]
    end

    Dify --> Ceiling
    Ceiling --> L1
    Ceiling --> L2

四、LangChain:把 Dify 节点拆成“模型 + 提示 + 解析 + 工具”

原文里最关键的两个节点是 LLM 参数提取器HTTP 请求。它们在 Dify 里是两个方块,但在 LangChain 里至少要拆成四块积木:

Dify 节点LangChain 组件拆分原因
LLM 参数提取器ChatModel + PromptTemplate + OutputParser模型、提示、解析三个职责分离,才能单独测试和版本化
API 参数构建器RunnableLambda / Python 函数把 Python 沙箱代码变成可导入、可单元测试的模块
HTTP 调用 ELK-MCPTool任何外部调用都抽象成工具,Agent 可以决定是否调用
失败重试with_retry / fallbacks重试策略写成代码,而不是节点配置里的下拉框

4.1 参数提取:从 Dify 节点到 Pydantic schema

Dify 的参数提取器最终输出 7 个字段。LangChain 里对应的做法是先定义一个 Pydantic model,再用 JsonOutputParser 约束模型输出:

from pydantic import BaseModel, Field
from langchain_core.output_parsers import JsonOutputParser
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# 1. 定义输出 schema:对应 Dify 参数提取器的 7 个输出变量
class LogQueryParams(BaseModel):
    """对应 Dify 参数提取器的输出变量。"""
    time_range_minutes: int = Field(default=15)
    log_levels: list[str] = Field(default=["ERROR"])
    services: list[str] = Field(default=[])
    keyword: str = Field(default="")
    tenant_id: str = Field(default="all")
    sort_field: str = Field(default="@timestamp")
    sort_order: str = Field(default="desc")

# 2. 模型 + 提示 + 解析:对应 Dify 的 LLM 节点 + 参数提取器
llm = ChatOpenAI(model="deepseek-v3", temperature=0)
prompt = ChatPromptTemplate.from_messages([
    ("system", "你是日志分析助手,只返回 JSON。"),
    (
        "human",
        PARAMETER_EXTRACTION_PROMPT,  # 上一篇文章里的完整提示词
    ),
])
parser = JsonOutputParser(pydantic_object=LogQueryParams)

extract_chain = prompt | llm | parser

这一步的关键变化是:输出结构被显式定义了。 Dify 的参数提取器也要求模型输出 JSON,但 schema 是写在提示词里的;LangChain 把 schema 提到了代码层面,Pydantic 会在解析失败时抛出明确异常,而不是让下游节点拿到一个缺字段的字典。

4.2 API 参数构建器:从 Dify 代码节点到可测试函数

原文里用 Python 代码节点做索引模式映射、租户识别、查询体构建。在 LangChain 里,这就是一个很普通的 Python 函数:

from langchain_core.runnables import RunnableLambda

def build_api_payload(params: LogQueryParams) -> dict:
    """把结构化参数转成 ELK-MCP 的请求体。"""
    return {
        "index_patterns": [f"{params.tenant_id}_log_*"],
        "filter": [
            {"terms": {"log_level": params.log_levels}},
            {"terms": {"service_name": params.services}},
            {"term": {"tenant_id": params.tenant_id}},
        ],
        "sort": [{params.sort_field: {"order": params.sort_order}}],
        "from": 0,
        "size": 50,
    }

# 现在可以写单元测试了
def test_build_api_payload():
    params = LogQueryParams(
        time_range_minutes=30,
        log_levels=["ERROR"],
        services=["web_scrm"],
        tenant_id="kf1",
    )
    payload = build_api_payload(params)
    assert payload["filter"][2]["term"]["tenant_id"] == "kf1"

这个函数可以被 pytest 单独测试、可以被 IDE 自动补全、可以被类型检查器校验。Dify 代码节点里的 Python 虽然也能写逻辑,但它生活在一个沙箱里,无法导入私有库、无法被版本控制工具完整理解。

4.3 工具与重试:从 Dify HTTP 节点到 Tool

Dify 的 HTTP 节点是一种特殊节点。LangChain 没有“HTTP 节点”这个概念,任何外部调用都被抽象成 Tool

from langchain_core.tools import tool
import requests

@tool
def query_elk_mcp(payload: dict) -> dict:
    """调用 ELK-MCP 日志查询 API,失败时抛异常。"""
    resp = requests.post(
        "http://elk-mcp:999/api/log/query",
        json=payload,
        headers={"Authorization": "Bearer token"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()

# 对应 Dify 的失败重试机制:3 次指数退避
safe_query = query_elk_mcp.with_retry(
    stop_after_attempt=3,
    wait_exponential_jitter=True,
)

# 也可以加兜底:主模型失败时换本地小模型
fallback_llm = ChatOpenAI(model="gpt-4o-mini")
robust_extract = extract_chain.with_fallbacks(
    [prompt | fallback_llm | parser]
)

4.4 LCEL:把节点连成一条可观测的链

LangChain 的 | 运算符叫 LCEL(LangChain Expression Language)。它看起来只是把几个组件串起来,但背后做了三件事:

  1. 自动类型传播:上一步输出类型会约束下一步输入。
  2. 流式支持chain.stream() 可以逐 token 输出。
  3. 可观测性:配合 LangSmith,每一步的输入输出都能被 trace。
# 原文工作流的核心骨架,现在变成一条链
chain = robust_extract | RunnableLambda(build_api_payload) | safe_query

# 流式运行,观察每一步的输入输出
for event in chain.stream({"query": "查询 kf1 最近 15 分钟 ERROR 日志"}):
    print(event)

这一步的目标不是把 Dify 替换掉,而是让你看清:每个节点都不是魔法,都是可以被拆解、被测试、被版本化的普通代码。

五、LangGraph:为什么 LangChain 之后必须上图

原文里的失败重试是节点级的:HTTP 请求失败,自动重试 3 次。这个机制对偶发网络抖动有效,但对更复杂的生产场景就不够了。

日志查询助手.yml 里的真实工作流比原文更复杂:它不只是调一次 API,而是要先初始化分页查询,拿到 total_pages,再一页一页拉回来,每页都让 LLM 做摘要,最后生成完整报告,还要把结果写入 MySQL。这才是生产环境的常态。

这种场景下,LangChain 的线性链会遇到四个死胡同:

  1. 无法表达循环:分页查询必须 for page in range(total_pages),链式语法 A | B | C 没有回头路。
  2. 没有显式状态:第 10 页的摘要需要引用第 1 页的上下文,链里得手动 threading。
  3. 崩溃后从头再来:如果拉到第 25 页时进程挂了,LangChain 没有内置机制让你从第 25 页恢复。
  4. 无法人机审批:如果“写入 MySQL”是高风险操作,链不能原路暂停等人点“同意”。

LangGraph 的答案是:把工作流建模成有状态的图。

flowchart TD
    subgraph Chain["LangChain 链式结构"]
        C1[Prompt] --> C2[LLM] --> C3[Parser] --> C4[Tool] --> C5[Output]
        C3 -. "失败只能从第 1 步重来" .-> C1
    end

    subgraph Graph["LangGraph 图结构"]
        G1[Prompt] --> G2[LLM] --> G3[Parser]
        G3 -->|"合法"| G4[Tool] --> G5[Output]
        G3 -->|"不合法"| G6[重试] --> G2
        G4 -->|"失败"| G7[错误处理]
        G4 -. "checkpoint 保存" .-> G8["断点恢复 / 人工审批"]
    end

把简单的事情做标准化,把复杂的事情做模块化。LangGraph 就是帮我把“循环 + 状态 + 恢复”做成标准模块的工具。

六、LangGraph 核心概念的精确定义:从 Dify 变量到 State / Node / Edge / Checkpointer / Interrupt

LangGraph 的五个核心概念,本质上都是把 Dify 里隐式的东西显式化。

State(状态)

Dify 的 conversation_variables 和节点输出就是状态,但弱类型、隐式传递。LangGraph 要求你显式定义:

from typing import TypedDict, Annotated
import operator

class LogState(TypedDict):
    """共享状态:对应 Dify 的 conversation_variables + 节点输出。"""
    query: str
    params: dict
    session_id: str
    total_pages: int
    current_page: int
    summary: Annotated[str, operator.add]  # 追加,不是覆盖
    approved: bool

Node(节点)

节点就是函数:def node(state: State) -> dict。它对应 Dify 画布上的一个方块。

def paginate(state: LogState):
    """对应 Dify 的 HTTP 分页查询节点。"""
    page = state["current_page"] + 1
    # 实际调用 ELK-MCP /paginate/get
    return {"current_page": page}

def summarize(state: LogState):
    """对应 Dify 的 LLM 逐页摘要节点。"""
    return {"summary": f"第 {state['current_page']} 页摘要...\n"}

Edge(边)

普通边是固定跳转,条件边根据状态路由。Dify 的连线 + if-else 节点,就是 LangGraph 的边。

from langgraph.graph import END

def should_continue(state: LogState):
    """对应 Dify 的条件分支节点。"""
    if state["current_page"] < state["total_pages"]:
        return "paginate"
    return "approve"

Checkpointer(检查点)

每个 super-step 结束后自动持久化状态。Dify 的执行日志只能“看历史”,LangGraph 的 checkpoint 能“回到历史并继续跑”。

from langgraph.checkpoint.memory import MemorySaver

app = builder.compile(checkpointer=MemorySaver())

# 崩溃后修复问题,用同一个 thread_id 从最近检查点恢复
app.invoke(
    None,
    config={"configurable": {"thread_id": "ops-001"}}
)

Interrupt(中断)

interrupt() 让图在任意节点暂停,等人输入。Dify 里实现这个通常靠“直接回复”节点 + 下一条用户消息,上下文管理是隐式的;LangGraph 把它变成显式原语:

from langgraph.types import interrupt, Command

def human_approval(state: LogState):
    """在写入 MySQL 前暂停,等待人工确认。"""
    decision = interrupt(
        {
            "message": "即将写入 MySQL,是否继续?",
            "summary": state["summary"],
        }
    )
    return {"approved": decision == "yes"}

# 用户通过 UI 回复后恢复
app.invoke(
    Command(resume="yes"),
    config={"configurable": {"thread_id": "ops-001"}}
)

这五个概念组合起来,就能把 日志查询助手.yml 里那条带分页、带审批、带落库的工作流,写成可控、可恢复、可测试的代码。

flowchart TD
    Start([开始]) --> Extract[&#34;extract_params<br/>参数提取&#34;]
    Extract --> Init[&#34;init_query<br/>初始化分页&#34;]
    Init --> Check{&#34;还有下一页?&#34;}
    Check -->|是| Paginate[&#34;paginate<br/>拉取一页&#34;]
    Paginate --> Summarize[&#34;summarize<br/>LLM 摘要&#34;]
    Summarize --> Check
    Check -->|否| Approve[&#34;human_approval<br/>人工审批&#34;]
    Approve --> Store[&#34;store<br/>写入 MySQL&#34;]
    Store --> End([结束])

七、Dify 与 LangGraph 的逐节点映射

把 Dify 的工作流翻译成 LangGraph,其实是一张一一对应的表:

Dify 概念LangGraph 概念为什么要这样对应
开始节点图的 entry point工作流的入口
LLM 节点调用 ChatModel 的 Node模型调用被包成一个函数
参数提取器OutputParser 所在的 Node把模型输出转成结构化状态
代码节点Python 函数 Node自定义计算逻辑
HTTP 请求节点Tool 调用 Node外部 API 调用
if-else 条件分支条件边 add_conditional_edges根据状态决定下一步
循环节点带返回边的 Node用条件边控制是否继续循环
等待节点普通 Node(sleep)或外部事件触发显式控制等待
直接回复节点输出 Node 或 interrupt返回用户或暂停等人
conversation_variablesState所有节点共享的强类型状态
执行日志Checkpointer + get_state_history不只是看历史,还能恢复和分叉
flowchart LR
    subgraph Dify[&#34;Dify 节点&#34;]
        D1[LLM]
        D2[代码]
        D3[HTTP]
        D4[if-else]
        D5[循环]
        D6[直接回复]
        D7[变量]
    end

    subgraph LG[&#34;LangGraph 概念&#34;]
        L1[Node 调用 ChatModel]
        L2[Node Python 函数]
        L3[Node 调用 Tool]
        L4[条件边]
        L5[循环边]
        L6[Output Node / Interrupt]
        L7[State]
    end

    D1 --> L1
    D2 --> L2
    D3 --> L3
    D4 --> L4
    D5 --> L5
    D6 --> L6
    D7 --> L7

八、生产级能力对比:异常边界、人机交互、可观测性

学习框架不能只看“正常流程怎么走”,更要看“出错时怎么办”。

能力DifyLangChainLangGraph
异常边界节点级错误分支链级 try/except + retry节点级 + checkpoint 恢复
重试机制HTTP/LLM 节点可配with_retry / fallbacks可在节点/条件边里自定义
崩溃恢复依赖执行日志,难恢复checkpointer 断点续跑
状态可见性执行日志 + 变量面板需手动打印get_state / get_state_history
人机审批UI 阻塞式无原生interrupt + Command(resume)
调试能力节点单独运行LangSmith tracetime travel + breakpoint

8.1 异常处理:Dify 的节点级 vs LangGraph 的图级

Dify 的错误处理是节点级的。每个 HTTP、LLM、代码节点都要单独开错误分支,复杂流程会画得很乱。而且它没有“全局异常边界”——只要有一个关键节点没处理错误,整个流程就停了,外部系统收不到统一的状态回调。

LangGraph 把异常处理提升到了“工作流级别”:

from typing import TypedDict

class LogState(TypedDict):
    error: str
    retry_count: int

def paginate(state: LogState):
    """带错误处理的节点。"""
    try:
        # 调用 ELK-MCP /paginate/get
        return {"retry_count": 0}
    except Exception as e:
        return {"error": str(e), "retry_count": state["retry_count"] + 1}

def route_on_error(state: LogState):
    """根据是否有错误决定下一步。"""
    if state["error"] and state["retry_count"] < 3:
        return "paginate"  # 重试
    if state["error"]:
        return "fallback"  # 超过次数,走兜底
    return "summarize"

配合 checkpointer,即使进程重启,你也能从失败的那一步继续重试,而不是从头再来。

8.2 人机交互:Dify 的阻塞式提问 vs LangGraph 的 interrupt

在 Dify 里实现人机交互,通常是在关键节点放一个“直接回复”节点,把问题抛给用户,等用户下一条消息进来再继续。这个模式有两个问题:

  1. 上下文耦合:用户回复的内容必须被正确解析并映射到下一步的变量,否则流程会跑偏。
  2. 无法跨会话恢复:如果用户关闭了窗口,下次再进来,Dify 不会记得上次停在哪个审批节点。

LangGraph 的 interrupt()工作流级的暂停。它保存状态、等待输入、恢复执行,完全解耦于 UI。你可以在前端做一个“待审批”列表,用户点“同意”后,后端用 Command(resume="yes") 继续。

8.3 Time Travel:Dify 做不到的时光旅行

假设最终生成的 SRE 报告有问题,你想回到“意图识别”那一步,把 serviceskf11 改成 kf12,再看看结果会不会更好。在 LangGraph 里:

# 拿到完整执行历史
history = list(app.get_state_history(config))

# 找到意图识别那一步的 checkpoint
target = next(
    c for c in history if "extract" in c.values.get("steps_completed", [])
)

# 修改状态
new_state = {**target.values, "services": ["kf12"]}

# 从那个检查点 fork 一条新线程继续
new_config = {"configurable": {"thread_id": "ops-001-variant"}}
app.update_state(new_config, new_state)
app.invoke(None, new_config)

在 Dify 里,你只能手动修改输入重新跑。LangGraph 让你可以“从历史的任意时刻分叉”。

九、我的学习路径:Dify → LangChain → LangGraph

理解了三个天花板之后,学习顺序就不再是“学完 A 学 B”,而是缺什么补什么

flowchart TD
    Start([开始]) --> Q1{&#34;流程是否固定?&#34;}
    Q1 -->|是| Q2{&#34;是否需要循环、状态持久化或人工审批?&#34;}
    Q2 -->|否| LC[&#34;LangChain / LCEL&#34;]
    Q2 -->|是| LG[&#34;LangGraph&#34;]
    Q1 -->|否| LG2[&#34;LangGraph 多步 Agent&#34;]

阶段 1:用 Dify 证明概念

目标:2 小时跑通对话式日志查询。 重点:理解节点、变量、分支;用原文的简化流程即可。

阶段 2:用 LangChain 拆积木

目标:把参数提取、API 构建、HTTP 调用重写成可测试的代码。 重点:学会 ChatModel / PromptTemplate / OutputParser / Tool / RunnableLambda 的分工。

阶段 3:用 LangGraph 补控制

目标:把分页循环、异常恢复、人工审批加进去。 重点:定义 State、写 Node、画 Edge、加 Checkpointer、用 Interrupt。

quadrantChart
    title &#34;Dify / LangChain / LangGraph 的易用性 vs 控制力&#34;
    x-axis &#34;易用性低&#34; --> &#34;易用性高&#34;
    y-axis &#34;控制力低&#34; --> &#34;控制力高&#34;
    quadrant-1 &#34;低代码优先:快速 demo&#34;
    quadrant-2 &#34;专业编排:复杂生产&#34;
    quadrant-3 &#34;简单脚本:临时任务&#34;
    quadrant-4 &#34;高度定制:精细控制&#34;
    &#34;Dify&#34;: [0.9, 0.3]
    &#34;LangChain&#34;: [0.5, 0.5]
    &#34;LangGraph&#34;: [0.3, 0.8]

写在最后

Dify 和代码框架不是替代关系,而是同一个流程在不同成熟度下的不同形态

  • Dify 让业务方 2 小时验证想法。
  • LangChain 让开发者把每个节点拆成可测试的组件。
  • LangGraph 让复杂流程具备生产级控制能力。

企业里 Dify 用得多,是因为它降低了启动成本;开发者回归代码,是因为代码降低了长期维护成本。理解了这一点,就不会再纠结“选哪个框架”,而是会看当前阶段缺什么能力。

踩过的坑,比读过的书更有价值。希望这篇续集能帮你把“为什么从 Dify 走向代码”这个问题,看得更清楚一点。


项目已开源:

欢迎 Star 和 Issue 反馈。

我是 magicCzc,一个把 AIOps 当信仰的运维开发工程师。
GitHub:github.com/magicCzc