☰
本地大模型智能助手实战:vLLM+LangGraph+DeepAgents部署与工具调用
2026/10/20 3:11:33 网站建设 项目流程

说实话,本地大模型最尴尬的时刻不是显存放不下模型,而是模型明明跑起来了,却只会一问一答地往外吐字。你让它查一下本地日志、算个平均数、连续办三件事,它就露馅:要么答非所问,要么直接胡编。我前后折腾过好几套本地智能助手方案,最后固定下来的搭配是 DeepAgents + LangGraph + vLLM,底层模型用 Qwen3.5:35B。这套结构的核心就一句话:用 vLLM 把模型变成稳定服务,用 LangGraph 把多轮对话和工具调用编排成状态图,再用 DeepAgents 把“规划—执行—反思”的智能体行为封装起来。这篇文章会把这套系统的选型逻辑、硬件门槛、部署命令、编排代码和联调排错完整写出来,适合已经会 Python、想把本地模型从“聊天玩具”升级成“能干活助手”的开发者。

1. 为什么是这套组合:本地助手的四个层次不能混为一谈

很多人一上来就问我:不就是本地起个模型吗,为什么不直接用最简单的对话界面,非要把推理引擎、状态图、智能体框架叠三层?我直接说结论:如果只是想要个聊天玩具,确实不用这么麻烦;但一旦要让模型去调工具、翻本地文件、查数据库、连续处理多步任务,单靠“模型 + 前端”这条路会越改越乱。

我的取舍逻辑是把它拆成四层:模型层负责“懂多少”,推理层负责“跑多快”,编排层负责“怎么走”,智能体层负责“按什么策略走”。这四件事各有各的成熟工具,硬塞进一个框架里反而互相拖累。

1.1 模型层:Qwen3.5:35B 这个档位为什么值得选

本地部署选模型是最先要定的决策。7B 级别的模型部署轻松,但工具调用能力和多步推理明显偏弱,复杂指令经常“听懂了但做不对”;70B 级别能力强,可推理成本直接翻倍,一般人的显卡根本扛不住。35B 恰好卡在中间:单卡 24GB 显存用 4bit 量化能跑,指令遵循、函数调用、长上下文这些智能体最依赖的能力又都在线。

我实测下来,Qwen3.5:35B 在工具调用上的稳定性明显好于同量级的多数模型。这里的“稳定”不是指回答多精彩,而是指它知道什么时候该输出结构化工具调用、什么时候该直接回答,这种边界感恰恰是智能体系统的命门。模型再聪明,如果老把工具调用格式写错,下游编排层就得花大量时间去兜底。

1.2 推理层:vLLM 和轻量方案的本质差异

模型选好之后,第二个问题是“用什么把它跑起来”。vLLM 的核心优势有三个:连续批处理、页式注意力管理、以及标准的 Chat Completions 协议接口。

连续批处理的意思是,不用等一条请求完全生成完才处理下一条,而是边生成边插队调度,这在 LangGraph 反复“一问一答”的场景里收益非常大。因为智能体跑一个任务往往要发起几十次小请求,每次请求的 prompt 又都带着同样的系统提示词,vLLM 的页式注意力能把 KV 缓存按页管理,配合前缀缓存,相同的前缀不会被重复计算。

有人会问为什么不用更轻的本地推理方案。轻量方案的优势是单用户响应快、显存占用小,但它是为“一个人聊天”设计的,在服务的稳定性、并发吞吐和协议兼容上不如 vLLM 完整。既然我们要把助手做成一个长期运行的本地服务,还要对接 LangGraph 这类编排框架,选一个协议标准、调度高效的推理服务是值得的。

1.3 编排层与智能体层:LangGraph 和 DeepAgents 各管哪一段

LangGraph 解决的是“状态怎么流转”。智能体任务天然不是一条直线,而是带条件分支和循环的图:模型要工具,工具调完结果回填,模型再看下一步,可能还要再调一次工具。LangGraph 把这种流程建模成节点和边,还支持把中间状态落盘,进程重启后能接着聊。

DeepAgents 解决的是“按什么策略走”。它把智能体最常见的“先定计划、再执行、执行完检查、不对就调整”这套行为封装成可直接使用的组件,你不用每次手写一遍反思循环的逻辑。在我的实际项目里,两者是合作关系:DeepAgents 负责生成智能体策略,LangGraph 负责把策略落地成可控、可恢复的状态流。

层次代表组件核心职责回答的问题
模型层Qwen3.5:35B理解与生成懂多少
推理层vLLM加载与推理服务跑多快
编排层LangGraph状态图与流程控制怎么走
智能体层DeepAgents规划、执行、反思按什么策略走

这套分层的好处是每一层都能独立替换。哪天出了更强的模型,换掉模型层就行;哪天觉得某个智能体策略不好用了,只改 DeepAgents 那一层,不用动整个系统。

2. 环境准备与硬件基线:先把“能不能跑”这件事说清楚

叠再多框架,最后都要落到一张显卡上。35B 模型不是不能跑,而是要搞清楚“怎么跑才不憋屈”。这一步搞错了,后面所有代码都是空中楼阁。

2.1 显存估算:别等启动报错才发现装不下

先算一笔账。35B 参数模型:

  • bf16 精度加载,权重占用约 70GB,加上 KV 缓存和运行时开销,单卡基本没戏,得上多卡。
  • INT8 量化,权重约 35GB,两张 24GB 显卡做张量并行勉强能跑。
  • AWQ/GPTQ 这类 INT4 量化,权重约 18GB 到 20GB,KV 缓存再占 4GB 到 8GB,单张 24GB 显存的卡正好能塞下。

所以我默认推荐:单卡 24GB 显存,用 AWQ 量化版权重,上下文长度控制在 16K 以内。如果只有 16GB 显存,硬上 35B 会非常痛苦——要么把上下文砍到 8K 以下,要么把量化压到更低,但效果和速度都会打折扣,这种配置不如老实换 14B 模型。

加载方式权重占用估算KV缓存与开销可选显卡配置
bf16约 70GB约 8GB 以上多卡 80GB 以上
INT8约 35GB约 8GB双卡 24GB 或单卡 48GB
INT4 AWQ/GPTQ约 20GB约 6GB单卡 24GB

这套估算不是精确值,但足够做决策。我的建议是给显存留 10% 到 15% 的余量,不要卡着算。vLLM 启动后模型权重、激活值、KV 缓存都要抢同一块显存,算得太满,第一个长请求过来就容易崩。

2.2 软件环境与依赖版本

Python 版本建议用 3.10 或 3.11,这是当前深度学习生态兼容性最好的区间。显卡驱动要保证 CUDA 运行时在 12.1 以上,否则 vLLM 的预编译轮子可能装不上。

conda create -n local_assistant python=3.11 -y conda activate local_assistant pip install "vllm==0.6.3" \ langgraph \ langchain-openai \ transformers \ deepagents

这里必须强调锁版本。vLLM 的小版本之间 API 差异很大,LangGraph 的节点写法也在快速演进,不锁版本的话,网上找的教程、我下面写的代码,到你机器上可能直接就跑不了。装完之后先做一次快速自检:

python -c "import vllm, langgraph, transformers; print('deps ok')" python -c "import torch; print('cuda:', torch.cuda.is_available(), torch.cuda.get_device_name(0))"

我的习惯是先用一个小模型把整个依赖链路跑通,再去加载 35B。这一步能帮你区分“环境问题”和“模型问题”,省下大量排查时间。

2.3 模型权重的准备

去模型托管平台拉取 Qwen3.5:35B 的 AWQ 量化权重,放到本地目录,比如./models/Qwen3.5-35B-Instruct-AWQ。下载后一定要检查目录完整性:必须包含config.json、tokenizer.json、tokenizer_config.json这些文件,缺一个都可能让 vLLM 在启动阶段报错。

这里有个容易被忽略的坑:AWQ 权重的config.json里会写清楚quantization_config字段,启动服务时指定的量化方式和权重实际格式必须一致。你下载的是 AWQ,启动命令里就要标明--quantization awq;你下载的是 GPTQ,就写 gptq。对不上,vLLM 会直接拒绝加载或者给出莫名其妙的报错。

3. vLLM 部署 Qwen3.5:35B:服务层是整套系统的地基

vLLM 跑起来之后,整个系统才算有了可以对话的对象。这一节我把启动命令、每个参数的含义、以及验证方法写得细一点,因为后面 LangGraph 和 DeepAgents 出的很多问题,根源都在这一层。

3.1 启动命令与关键参数解读

python -m vllm.entrypoints.openai.api_server \ --model ./models/Qwen3.5-35B-Instruct-AWQ \ --served-model-name qwen35 \ --max-model-len 16384 \ --gpu-memory-utilization 0.90 \ --quantization awq \ --dtype half \ --port 8000

逐个说参数:

  • --model指向本地权重目录。用本地路径而不是直接写模型名,可以避免每次启动都去联网检查。
  • --served-model-name qwen35给模型起一个对外名字。客户端请求时model字段必须填这个值,不是原始模型名。很多人忽略这个,后面 LangGraph 里模型名对不上,报 404 或者 400 都不知道为什么。
  • --max-model-len 16384是最大上下文长度。这个值直接决定 KV 缓存能开多大,也是显存占用的大头之一。
  • --gpu-memory-utilization 0.90表示 vLLM 最多可用 90% 显存。开太高容易 OOM,太低浪费性能,一般 0.85 到 0.92 之间调。
  • --quantization awq必须和权重格式一致。
  • --dtype half用半精度推理,配合量化权重是常规配置。

启动后要会看日志:出现类似Starting vLLM API server on http://0.0.0.0:8000的提示,才是真正就绪。加载 35B 模型通常要一两分钟,期间显存会被逐渐吃满,不要一看卡住就 Ctrl+C。

3.2 用 Chat Completions 协议做健康检查

服务起来先别急着写代码,先用 curl 验证一遍。

# 查看已加载模型 curl -s http://localhost:8000/v1/models | python -m json.tool # 发一条对话请求 curl -s http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen35", "messages": [{"role": "user", "content": "用一句话介绍你自己"}], "max_tokens": 128 }' | python -m json.tool

/v1/models返回结果里的模型 id 如果和--served-model-name一致,说明服务正常。对话请求能返回非空content,说明推理链路没问题。这一步做完,相当于确认了“地基”是稳的。

顺便提一句,vLLM 这个接口协议是行业里通用的标准格式,后面 LangGraph 的客户端封装直接指到这个地址就能用,这也是我坚持用 vLLM 而不是自建一个推理脚本的原因——协议统一,上层只管发请求,不用关心底层推理细节。

3.3 针对智能体场景的额外调优

跑通之后,建议按智能体场景再做两个优化:

第一,开启前缀缓存。智能体每次请求都会带上同样的系统提示词和可能很长的历史消息,前缀缓存能把这些重复计算直接省掉。启动参数加--enable-prefix-caching,实测长任务场景下首 token 延迟能明显下降。

第二,如果遇到显存峰值波动,考虑加--enable-chunked-prefill。它把长输入的预填充阶段拆成小块,显存占用更平稳,代价是吞吐略降。我遇到过一个很典型的情况:短请求一切正常,一跑长文档分析就随机 OOM,开启 chunked prefill 之后问题消失。这就是典型的预填充显存尖峰问题。

这一层调整好之后,LangGraph 和 DeepAgents 的很多“为什么这么慢”“为什么突然崩”的问题,都已经被提前消灭了。这也是我坚持先把服务层调稳再写上层代码的原因。

4. LangGraph 编排层:把“助手”拆成一张状态图

模型服务就绪后,最难的不是让模型说话,而是让整个多轮流程可控。LangGraph 在这里干的事,通俗说就是把助手的思考过程变成一张带环的图:模型想调工具,就走到工具节点;工具返回结果,再走回模型节点,如此循环直到模型认为可以给出最终答案。

4.1 为什么图结构比线性链更适合智能体

早期 LangChain 风格的链是线性的:先做什么、再做什么,写死成一条流水线。但真实助手任务不是线性的。你让模型“统计最近一周日志里的错误类型”,它可能需要先查日志目录、再看看错误码、再统计,每一步的结果都决定下一步动作。线性链没法表达这种“根据上一节点结果决定下一步去哪”的逻辑,而图结构天生就支持条件分支和循环。

还有一点是状态管理。多轮对话里消息列表越来越长,中间还可能夹着工具调用的输入输出。LangGraph 用共享状态对象管理这一切,每个节点读状态、改状态,最后统一提交,这让调试变得非常直观。

4.2 状态、节点与条件路由的完整代码

下面这段是我项目里的核心骨架,你可以直接抄去改。

import json from typing import Annotated, TypedDict from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages from langgraph.schema import ToolMessage class AgentState(TypedDict): messages: Annotated[list, add_messages] # 多轮消息,自动追加 plan: list # 当前计划步骤 tool_results: dict # 工具结果缓存 # 指向 vLLM 服务 llm = ChatOpenAI( base_url="http://localhost:8000/v1", api_key="sk-local", # vLLM 默认不校验,随便填 model="qwen35", temperature=0.3, max_tokens=2048, )

Annotated[list, add_messages]是 LangGraph 里最常用的状态注解:每次节点返回新的 message,都会自动追加到历史列表,而不是覆盖。多轮对话要的就是这个行为。如果你把它写成普通list,每轮只保留最后一轮消息,模型很快就“失忆”了。

接下来定义节点和路由:

def assistant_node(state: AgentState): """模型节点:决定是回答还是调用工具""" response = llm.bind_tools(tools)(state["messages"]) return {"messages": [response]} def tool_node(state: AgentState): """工具节点:执行模型发起的工具调用""" last_message = state["messages"][-1] outputs = [] for call in last_message.tool_calls: tool = tools_by_name[call["name"]] result = tool.invoke(call["args"]) outputs.append( ToolMessage( content=json.dumps(result, ensure_ascii=False), tool_call_id=call["id"], ) ) return {"messages": outputs} def should_continue(state: AgentState): """条件路由:还有工具调用就继续走工具,否则结束""" last_message = state["messages"][-1] if getattr(last_message, "tool_calls", None): return "tools" return "end" builder = StateGraph(AgentState) builder.add_node("assistant", assistant_node) builder.add_node("tools", tool_node) builder.set_entry_point("assistant") builder.add_conditional_edges( "assistant", should_continue, {"tools": "tools", "end": END}, ) builder.add_edge("tools", "assistant") # 工具执行完回到模型节点 app = builder.compile()

这段代码最核心的是should_continue:模型如果返回了tool_calls,就说明它还想调工具,图就继续循环;如果没有工具调用,就说明可以收尾了。这个循环结构就是智能体区别于普通聊天的本质。

跑起来的方式:

config = {"recursion_limit": 50} for event in app.stream( {"messages": [{"role": "user", "content": "统计本地日志里的错误类型分布"}]}, config, ): print(event)

recursion_limit是 LangGraph 的防呆机制,默认值一般能覆盖简单任务,但那种“查一下、再查一下、再汇总”的多步任务很容易超。我建议设成 50 起步,等业务稳定后再收紧,防止模型陷入死循环把资源吃光。

4.3 多轮记忆与会话持久化

光有状态还不够,真实助手还得能记住“上一轮聊到哪了”。LangGraph 的做法是配合检查点机制:给每次会话一个thread_id,中间状态自动落盘。

config = { "recursion_limit": 50, "configurable": {"thread_id": "session-001"}, }

之后同一thread_id继续发起请求,模型能读到之前的完整历史。这个能力在本地助手场景很值钱:用户离开半天回来,问“刚才那个统计结果再细化一下”,系统还能接得上。

上下文无限增长会撞上max_model_len,所以长会话还得加一个摘要节点:当消息列表长度超过阈值,调用模型把旧对话压缩成一段摘要,再保留最近几轮完整消息。阈值怎么定?我一般按“历史消息总 token 数超过模型上下文一半”来触发,既保记忆又不爆窗口。

5. DeepAgents 接入:工具调用与任务规划的落地细节

LangGraph 把流程骨架搭好了,但“模型到底应该怎么规划一个复杂任务”这件事,还是得有个具体策略。DeepAgents 在这一层帮我把智能体的行为模式固化下来。

5.1 DeepAgents 在这一层解决什么问题

手写 LangGraph 节点很灵活,但每次新项目都要重写“先规划、再执行、失败就反思”这套逻辑,太累也太容易漏。DeepAgents 把这种最常见的智能体循环封装成 Agent 对象,我只需要告诉它“用哪个模型、有哪些工具、系统提示词是什么”,剩下的事它自己处理。

它的核心价值是两件事:一是工具注册,把普通 Python 函数自动转成模型能理解的工具描述;二是反思循环,执行结果不理想时自动调整计划而不是硬着头皮往下走。对 35B 这个量级的模型来说,显式地让它“先写计划再动手”,比直接扔一个复杂问题给它,成功率要高得多。

下面这段是示意代码,DeepAgents 不同小版本的 API 会有差异,以你安装版本的文档为准,但核心思想不变:

from deepagents import Agent agent = Agent( llm=llm, system_prompt=( "你是运行在本地的智能助手。回答要基于工具返回的真实数据," "不要编造。复杂任务先制定计划,再逐步执行。" ), tools=[query_recent_logs, analyze_errors], max_plan_steps=5, ) answer = agent.run("统计最近一周日志中的错误类型分布并给出前三个高频错误")

5.2 工具注册:写好函数和描述就够了

工具是智能体的“手脚”,注册过程其实很朴素:写一个普通 Python 函数,把参数注释写清楚,框架会帮你把函数签名和 docstring 解析成模型能读懂的 JSON Schema。

def query_recent_logs(days: int, path: str = "./logs") -> str: """读取最近 N 天的日志文件并返回原文片段。 参数: days: 往前回看的天数,必须是正整数 path: 日志目录路径,默认 ./logs 返回: 日志原文的 JSON 字符串,包含日期和内容 """ # 实际实现:遍历目录、按日期过滤、返回 JSON 字符串 ...

我踩过的坑是:工具返回值类型要么是 JSON 字符串,要么是能被序列化的普通数据结构,千万不要返回自定义对象。模型拿到\{"log_level": "error", "count": 12\}这样的字符串,它知道下一步能解析;拿到一个 Python 对象,它在文本空间里根本没法处理。

另外,工具 docstring 里要把每个参数的用途写具体,甚至写清楚边界条件。模型是靠这段文字来理解“什么时候该用这个工具”的,描述模糊就等于这个工具不存在。我见过一个案例:某个查询工具的path参数没写默认值,模型每次都要猜路径,正确答案率直接掉了一大截。

5.3 计划—执行—反思循环的落地逻辑

DeepAgents 的循环可以理解成四个阶段:

  1. 计划:读取用户请求、系统提示词、工具清单,让模型输出一个步骤列表。
  2. 执行:依次调用步骤里涉及的工具。
  3. 反思:把工具结果和初始目标做对比,判断有没有达到目标。
  4. 调整:没达到就修改计划,重新执行;达到就直接整理成最终答案。

有个细节:计划阶段建议把温度调低,比如 0.2 到 0.3,让模型少一点“自由发挥”;最终总结阶段可以稍微拉高一点,让回答更自然。在同一个llm对象下面,我习惯单独建一个低温实例给规划节点用。

对 35B 来说,显式的反思步骤还有一个额外好处:它能“修正”自己第一轮的错误。有一次我让它统计错误日志,它第一步把日志目录路径搞错了,返回空结果。反思节点发现了“结果为空,和预期不符”,自动调整计划换了一个路径重新查,最后拿到了正确数据。这种自我纠错能力,正是智能体和普通问答的最大区别。

6. 联调与实测:让助手真正“聪明”起来的调整路径

骨架搭完了,代码也写完了,真正让人掉头发的是联调阶段。我把一次完整任务的运行链路、高频报错的排查套路、以及从 Demo 到日常可用的差距,一次性写清楚。

6.1 一次完整任务的运行链路

以“统计本地日志最近一周的错误类型占比”为例,完整链路是这样的:

  1. 用户消息进入 LangGraph 的assistant节点。
  2. 模型输出计划,同时发起第一次工具调用:query_recent_logs(days=7)。
  3. 图走到tools节点,执行工具,把日志摘要作为ToolMessage回填。
  4. 图回到assistant节点,模型看到日志内容,发起第二次调用:analyze_errors(log_text=...)。
  5. 工具返回统计结果,模型整理成自然语言,不再发起工具调用,路由走到结束。

我实测下来,35B 在这个流程里的表现相当稳定:

指标实测参考值
首 token 延迟(中等长度输入)约 400ms 到 800ms
生成速度约 13 到 17 token/s
一次带两次工具调用的完整任务约 15 到 25 秒
工具调用格式错误率约 2% 到 5%,重复请求后可忽略

这个速度谈不上惊艳,但对本地助手来说完全可接受。注意这些数值会受显卡、上下文长度、并发数影响,别拿我的数字当标准线,重要的是“能稳定跑完”而不是“跑得快”。

6.2 常见报错与排查链路

联调阶段最让人头疼的就是各类报错,我把高频问题整理成一张表:

现象根因解决方案
模型输出普通文本,不发起工具调用对话模板不支持 function calling升级 transformers,或给 vLLM 显式指定 chat template
LangGraph 报 Expected ToolMessagetool_call_id没对齐确认工具结果用ToolMessage且 id 取自模型返回的tool_calls
长请求随机 OOM预填充阶段显存尖峰加--enable-chunked-prefill,或调低--gpu-memory-utilization
Recursion limit exceeded循环太深或模型陷入反复调工具调高recursion_limit,同时给反思循环加最大轮数
请求超时vLLM 排队过长或单次生成太久客户端 timeout 调大,或限制max_tokens
调用工具参数乱填模型对参数定义理解不足把工具参数描述写细,或用 pydantic 约束参数结构

我的排查套路是自底向上:先在 vLLM 层用 curl 直接发一条带工具声明的请求,确认模型自己能输出正确的工具调用;再去 LangGraph 里逐节点打印 state,看消息流到底断在哪一环。很多人一上来就改上层代码,结果发现是 vLLM 的模板问题,白白浪费一下午。

6.3 从 Demo 到日常可用的最后一步

本地助手项目做到能跑只是第一步,真正日常可用还要注意三件事。

第一,把服务托管起来。用守护进程或任务计划工具让 vLLM 长期运行,不要每次用都手动开终端。vLLM 自带/metrics接口,可以用监控工具盯显存、吞吐、排队时长,内存快满时提前干预。

第二,接入私有数据的思路要摆正。别想着把整个文档库塞进上下文,而是把 RAG 检索封装成一个普通工具。模型需要信息时就调用“检索工具”,拿到相关内容再回答。这样既省上下文,又能让助手回答私有问题时不容易胡编。

第三,先用小模型做冒烟测试。我现在的标准流程是:新功能先拿 7B 模型跑通逻辑,再切回 35B 看效果。小模型跑得快、试错成本低,等逻辑稳定了再换大模型,能省大量时间。真正切换时只改--model和--served-model-name两个地方,其他都不用动。

最后再分享一个我自己的经验:这套系统调通之后,一定要把“模型能力边界”记下来。哪些任务它稳定可靠,哪些任务它经常出错,整理成一份清单。因为智能体系统的上限是模型决定的,深度学习框架再花哨也改变不了这一点。清楚它能干什么、不能干什么,你才知道下一步该去调工具、调提示词、还是直接换更大的模型——这三个方向的工作量,差距可不止一个量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询