在真正把 Agent 应用搬进隔离内网之前,我一度以为这活儿和平时部署一个后端服务没什么两样——起几个 Python 进程、挂一个模型服务、暴露一堆 API 就完事了。等真到了离线的环境里,模型权重怎么进来、依赖包怎么传、外部搜索引擎和向量库还能不能用、并发一上来 GPU 怎么不被打爆,这些问题一个比一个扎心。
这篇文章想把我在隔离内网里从零到一落地 AI Agent 的完整过程掰开揉碎讲清楚:先说隔离内网到底卡在哪几个环节,再做架构选型(为什么最后选了 FastAPI + LangGraph + vLLM 而不是别家),然后讲离线模型搬运、依赖打包这些最脏最累的活,接着是 Agent 核心链路与工具调用的实现,最后单独聊一聊 AI Agent 怎么扛并发、token 怎么治理和监控。适合正在做私有化部署、信创环境落地、或者任何有严格网络隔离要求的 Agent 项目的工程师,也适合在大模型应用里刚起步、想一口气把部署链路看明白的人。
1. 先看清战场:隔离内网到底卡在哪四个环节
很多人一听到“隔离内网”,第一反应是“不就是少个网络吗,装个安装包进去不就行了”。真上手了才知道,少的不只是网络出口,而是整个软件交付链路里所有默认联网的部分全都要改成离线方案。我把踩过的坑归纳成四道门槛,每一道都拦得住一整批人。
1.1 第一道门槛:模型权重怎么运进内网
大模型本身不是一个文件,而是权重、tokenizer、配置文件、可能还有分词器和模板文件的集合。比如一个 7B 参数的模型,FP16 精度下光权重就要 14GB 左右,加上中间产物做核对校验,实打实超过 15GB。日常我们from_pretrained("Qwen/Qwen2.5-7B-Instruct")一下就能把模型拉下来,但内网机器访问不了 HuggingFace,也访问不了 ModelScope,这个动作直接就废了。
现实情况是:必须在一台能联网的机器上先把模型完整下载、校验、打包,再通过审批流程和介质(内部网盘、光盘或专用拷贝终端)运进内网。这里有个很多人第一次会踩的坑——下载的时候只下载了权重文件,忘了 tokenizer 和配置文件,结果模型进到内网后加载直接报错。更隐蔽的问题是,HuggingFace 的仓库结构里有些文件是软链或 LFS 指针,如果只按tree看到文件就复制,拷进去的可能是几百字节的指针文件,而不是真正的权重。
1.2 第二道门槛:Python 依赖和容器镜像出不去
你以为把模型搬进去就完了?Agent 项目几乎必然依赖 LangChain、LangGraph、FastAPI、Pydantic、OpenAI SDK 这一堆库。在能联网的开发机上,pip install一行命令就装完了,但内网机器没有 PyPI 源,断网安装就变成了一场噩梦。尤其是 torch、transformers、vllm 这种带 CUDA 扩展的大包,动辄好几个 GB,装错一个版本依赖就直接把环境搞废。
容器镜像也是同样的道理。你可以在外网把docker pull下来的镜像docker save成 tar 包运进内网,再docker load导入。但有一个坑必须提醒:很多镜像的 tag 是latest,过了几周镜像就更新了,内网里你再想拉就是另一个人间惨剧。所以离线交付时我强烈建议把镜像 tag 全部固定到具体版本(比如v0.6.3.post1),并且把完整依赖清单导出来一起带走。
1.3 第三道门槛:Agent 的工具调用全线断网
AI Agent 和普通聊天机器人最大的区别就是会调用工具。常规 Demo 里大家爱用搜索引擎搜索、天气查询、浏览器访问网页,但这套逻辑搬到隔离内网后全部失效。我见过最惨烈的现场是:Agent 在跑一个查询任务时,内部逻辑尝试请求公网搜索 API,结果因为网络不可达,每个请求都卡到超时,一个对话要等三分钟才返回错误,直接把体验干崩。
内网环境下的工具设计必须换思路:搜索要换成内部的 Elasticsearch 或者企业知识库,外部 API 要换成内网服务,浏览器工具基本要砍掉。这意味着 Agent 的整个工具链在架构上就要为“内网可用”做约束设计,而不是最后再打补丁。
1.4 第四道门槛:观测与持续交付被腰斩
在外网开发时,日志可以打到云上,监控面板开箱即用,模型更新直接拉最新权重。到了隔离内网,这些全都没了。没有现成的监控 SaaS、没有在线文档、想升级模型版本得重新走一遍介质拷入的流程。连排查问题都变得费劲:不能pip install临时装一个工具来看网络包,不能用在线 debugger,连复制一行报错到网上搜索都不行。
所以隔离内网项目对技术选型的要求是:一切组件尽可能在内网自闭环,日志要落到内网 ELK,监控要起内网 Prometheus,有能力还要搭内部 PyPI 和镜像仓库。前期这些准备工作虽然枯燥,但能省掉后面几个月踩坑的时间。
2. 架构选型:推理引擎与 Agent 编排怎么组合
想清楚困难点之后,接下来是把技术栈定下来。这个环节我对比过好几组方案,包括用 Ollama 做推理、用 Spring AI、甚至有人提议用 Rust 重写 Agent 层。这里把我的取舍逻辑完整讲一下,方便你少走弯路。
2.1 推理引擎三选一:vLLM、Ollama 还是 llama.cpp
推理引擎的选择基本决定了整个系统的并发上限和部署复杂度,不能拍脑袋。我把主流的三个方案放在一起比过:
| 项目 | vLLM | Ollama | llama.cpp |
|---|---|---|---|
| 部署复杂度 | 中高,需 Python 环境与 CUDA 适配 | 低,一条命令起服务 | 低,可纯 CPU 跑 |
| 并发吞吐 | 高,continuous batching 优秀 | 中低,不适合高并发 | 低,通常单路推理 |
| 显存控制 | 支持量化、swap 到 CPU | 可配置,但控制力弱 | 极低显存可跑 |
| 生产可信度 | 高,社区和厂商标配 | 多用于个人和原型 | 多用于边缘设备 |
| 内网部署友好度 | 需离线打包较多依赖 | 单二进制/少量文件,最容易 | 单二进制,最容易 |
我最终选的是 vLLM,理由很简单:隔离内网一旦上线,用户量和并发是确定的,与其在 Ollama 上把性能压到极限,不如一开始就把推理层的并发能力做足。Ollama 的 API 和 OpenAI 兼容,加上一句环境变量就能切换,我会用它来做功能验证和冒烟测试,但生产环境留给 vLLM。至于 llama.cpp,我保留一个 CPU 版本做应急,万一 GPU 卡故障,还能用 CPU 先把服务撑起来,不至于全挂。
如果你对外的接口是标准的/v1/chat/completions,那么推理引擎本身是相对可替换的,这也是我敢同时保留多套引擎的原因。架构上不要让业务代码直接依赖某个引擎的私有 SDK,而是统一走 OpenAI 兼容协议,这一点在隔离网里尤其重要——换引擎不需要改上层。
2.2 Agent 编排:LangGraph 为主,FastAPI 做接入层
Agent 这一层的选型是争论最多的。有人用 LangChain 一把梭,有人用 Spring AI,有人提议用 Rust 生态,我还见过直接用 Pythonwhile循环自己写状态机的。我的最终选择是 FastAPI + LangGraph,核心原因是 LangGraph 的图状态模型和隔离内网里的长链路任务太匹配了。
LangGraph 的核心抽象是 StateGraph:把 Agent 的工作流拆成节点,节点之间用边连接,每个节点读写共享状态。这比 LangChain 的 Chain 更适合表达“先调工具、再决定是继续调还是返回结果”这种带条件分支的流程。而且 LangGraph 内置 Checkpointer 接口,可以把每一步的状态存到 Redis 或 PostgreSQL 里,进程重启之后还能把对话上下文恢复到中断的节点,这在长任务场景里是刚需。
为什么不用 Spring AI?如果你的团队全是 Java,可以理解,但 Spring AI 的 Agent 编排能力目前还是偏薄,图编排、状态恢复、工具路由这些能力都不如 LangGraph 成熟,最后还是要绕回去。为什么不用 Rust?Rust 的确能扛并发,性能也漂亮,但 Agent 生态(模型加载、Embedding、工具链)几乎都在 Python 生态里,隔离内网里遇到问题想找参考实现都难。我的观点是:Python 的性能瓶颈通过异步和横向扩展解决,不要为了语言性能牺牲开发效率和生态成熟度。
2.3 一台生产可用的端到端组件拓扑
整个系统架构并不复杂,我列一下生产环境里真正跑起来的组件清单:
| 模块 | 组件 | 作用 |
|---|---|---|
| 入口网关 | Nginx / 内部网关 | 路由、鉴权、基础限流 |
| API 服务 | FastAPI | 接收外部请求,做参数校验和会话管理 |
| Agent 编排 | LangGraph | 状态图管理、工具路由、多步任务调度 |
| 推理服务 | vLLM | 大模型推理,兼容 OpenAI 协议 |
| 会话存储 | Redis + PostgreSQL | 短期会话状态、长期历史记录 |
| 向量库 | Milvus / ES | 知识库检索,工具数据来源 |
| 监控日志 | Prometheus + Grafana + ELK | 指标、日志、告警 |
请求链路大概是这样的:用户请求先进 Nginx,网关做一层限流和权限校验;接着 FastAPI 拿到请求,把它投递到异步任务队列,LangGraph 开始编排;Agent 每一步需要推理时调 vLLM 的接口,需要知识时查内网向量库或内部服务;最终把结果写回会话存储,再通过 WebSocket 或轮询返回给前端。
这里要提醒一句:不要把 FastAPI 的进程和 LangGraph 的 worker 放在同一个进程里跑长任务,尤其是有工具调用的多步 Agent,单个请求可能需要几十秒甚至几分钟。如果直接阻塞在 FastAPI 的请求线程里,只要来十个并发请求,整个 API 服务就瘫痪了。后面讲并发的时候我会展开说。
3. 离线搬运实操:模型、依赖、镜像一个都不能少
技术栈定了,接下来是隔离内网里最痛苦的部分——把外网准备好的物料搬进去。这块表面上是体力活,实际上隐藏着大量版本、路径、权限相关的坑,我按顺序讲一遍可以复用的流程。
3.1 模型权重离线迁移与校验
我推荐用 ModelScope 而不是 HuggingFace 来做下载,因为国内网络下载速度更稳定,而且modelscope的 SDK 支持snapshot_download,可以一次性把整个仓库拉下来。命令大致是这样:
# 在能联网的开发机上执行 pip install modelscope modelscope download --model Qwen/Qwen2.5-7B-Instruct --local_dir ./qwen-7b-instruct如果你必须在 HuggingFace 上取模型,也可以用huggingface_hub的snapshot_download,前面加上代理配置,但为了速度我一般都优先 ModelScope。下载完成后不要急着打包,先把关键文件列出来看一眼:
find ./qwen-7b-instruct -type f -exec ls -lh {} \;重点确认这几类文件都在:config.json、tokenizer.json、tokenizer_config.json、generation_config.json、模型权重文件(safetensors 或 bin)。校验文件完整性也是一定要做的,用sha256sum生成校验清单,到内网后再核对一遍,防止介质拷贝过程中文件损坏。这个步骤看起来多余,但拷贝大文件时发生过静默损坏,模型都加载到一半了才报文件头错误,最后排查了很久才找到是文件坏了。
校验没问题之后打包:
tar -czf qwen-7b-instruct.tar.gz ./qwen-7b-instruct到内网之后解压,设置环境变量HF_HUB_OFFLINE=1和TRANSFORMERS_OFFLINE=1,然后写一个冒烟脚本加载模型跑一次推理,确认模型能正常输出再继续。永远不要跳过这个冒烟步骤,很多内网问题是在这一步才暴露的。
3.2 pip 依赖离线打包与内网安装
依赖打包最稳妥的方案是 pip download 全部装好之后,把 wheel 文件一起带进去。在联网机器上执行:
pip download -r requirements.txt -d ./wheelhouse注意这里有个细节:pip download默认只下载当前平台对应的 wheel,但你部署的机器可能 CPU 架构不一样,或者有多个 Python 版本,所以务必带上--platform和--python-version参数,或者最简单的方法是在一台和生产环境系统完全一致(同样 CPU 架构、同样 CUDA 版本、同样 Python 版本)的机器上打包。torch 和 vllm 这种带 CUDA 扩展的包尤其敏感,cu118 的轮子装到 cu121 的环境里大概率起不来。
到了内网之后安装:
pip install --no-index --find-links=./wheelhouse -r requirements.txt--no-index的意思是绝不访问 PyPI 源,--find-links指定本地 wheel 目录。如果缺了某个依赖,它会明确提示缺哪个,你回到外网补下再打包即可。对于长期维护的团队,更推荐在内网搭一个 devpi 或 Nexus 私服,以后直接把内网源配成默认 PyPI,所有机器统一走内网源,能省下大量重复打包的时间。
3.3 容器镜像与模型服务的内网分发
如果用了容器化部署,镜像的离线搬运和模型权重是同一条路。外网机器上先docker pull,然后docker save:
docker pull vllm/vllm-openai:v0.6.3.post1 docker save vllm/vllm-openai:v0.6.3.post1 -o vllm-image.tar拿到内网后docker load -i vllm-image.tar就行。这里有一个非常容易被忽视的点:docker save的 tar 包包含的是镜像的全部分层,体积会很大,比如 vllm 的镜像加上 CUDA 基础层可能 10GB 起步,拷贝的时候要提前确认内网机器磁盘空间够不够,别到现场才手忙脚乱。
我建议在内网搭建一个 Docker Registry(Harbor 或简单 Registry),把离线导入的镜像推上去,后续所有节点统一从内网 Registry 拉取。运维层面这几乎是一劳永逸,否则每个节点都要手动 load 一次,机器多了绝对是一场灾难。
4. Agent 核心链路实现:FastAPI + LangGraph 落地细节
物料到位之后,才是真正写代码的时刻。这一节我会给出一个可以直接抄作业的最小实现骨架,同时把工具调用和会话持久化这两个关键点讲透。
4.1 最小可用 Agent 服务骨架
我习惯把服务拆成两层:一层是 FastAPI 的接入层,负责 HTTP 请求解析和鉴权;另一层是 LangGraph 的编排层,负责 Agent 逻辑。下面是一个最小可用的示例,去掉鉴权和复杂的 Prompt 模板,只保留核心链路。
from fastapi import FastAPI from langgraph.graph import StateGraph, END from typing import TypedDict, Annotated import asyncio class AgentState(TypedDict): messages: Annotated[list, "conversation history"] tool_result: str app = FastAPI() # 1. 定义 LLM 调用层,走 OpenAI 兼容协议,指向内网 vLLM from openai import AsyncOpenAI client = AsyncOpenAI(base_url="http://vllm-service:8000/v1", api_key="internal") async def call_llm(state: AgentState) -> AgentState: resp = await client.chat.completions.create( model="qwen2.5-7b-instruct", messages=state["messages"], temperature=0.3, max_tokens=1024, ) state["messages"].append({"role": "assistant", "content": resp.choices[0].message.content}) return state # 2. 定义工具节点,这里以内部订单查询为例 async def query_internal_order(state: AgentState) -> AgentState: # 实际场景里在这里调内网订单服务 state["tool_result"] = "order-2024-001: 已发货" return state # 3. 编排 LangGraph 状态图 graph = StateGraph(AgentState) graph.add_node("llm", call_llm) graph.add_node("tool", query_internal_order) graph.add_edge("llm", "tool") graph.add_edge("tool", "llm") graph.add_edge("llm", END) # 简化逻辑,实际要用条件边判断是否继续 agent_app = graph.compile() @app.post("/chat") async def chat(request: dict): messages = request["messages"] # 把请求交付给 Agent 图执行,注意这里用异步避免阻塞 state = {"messages": messages, "tool_result": ""} final_state = await agent_app.ainvoke(state) return {"reply": final_state["messages"][-1]["content"]}这段代码里,我刻意把“条件边”省略成简化写法,但实际生产里一定是用add_conditional_edges来判断:模型输出的内容里是直接回答还是请求调用工具、工具执行完要不要再回 LLM。判断逻辑可以是在 system prompt 里约定输出 JSON,也可以依赖模型的 function calling 能力。这里建议直接走 OpenAI 兼容的 tools 参数,vLLM 对 function calling 的支持已经比较成熟。
4.2 工具函数注册与 function calling 实战
在 LangGraph 里,工具节点本质是一个 Python Async 函数。为了让大模型知道有哪些工具可用,需要把函数描述成 JSON Schema 传给模型。vLLM 的/v1/chat/completions接口支持tools参数,工具声明长这样:
tools = [ { "type": "function", "function": { "name": "query_internal_order", "description": "查询内部系统里的订单状态,仅支持订单号精确查询", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "订单号,例如 order-2024-001"} }, "required": ["order_id"] } } } ]模型接收到工具声明后,在需要调用工具时会返回一个tool_calls字段,里面是结构化的函数名和参数,而不是自由文本。你的代码要做的就是解析tool_calls,把参数取出来执行对应的内部函数,再把执行结果作为一条role="tool"的消息追加回多轮对话里,继续让模型决定下一步。
这里有一个我在内网环境里踩过的坑:极少数情况下,模型返回的tool_calls里name字段和注册的工具名称不完全一致(比如多了前后空格),实际执行时直接报工具未注册。建议在所有工具调用的入口加一个 name 归一化处理,name.strip()之后再做匹配。
4.3 会话状态持久化与幂等控制
隔离内网里的 Agent 通常会服务很多内部用户,多轮对话必须带上历史消息。我的方案是:
- Redis 存短期会话状态:以
session_id为 key,存最近 N 轮消息,TTL 设置成 30 分钟到数小时; - PostgreSQL 存长期会话归档:包括用户 ID、完整对话记录、token 消耗、最终结果,用于审计和回溯。
LangGraph 的 Checkpointer 可以天然支持这个场景。你只需要把checkpointer实例传给compile:
from langgraph.checkpoint.postgres import PostgresSaver checkpointer = PostgresSaver.from_conn_string("postgresql://user:pass@db:5432/agent") agent_app = graph.compile(checkpointer=checkpointer)之后每次执行传入config={"configurable": {"thread_id": session_id}},图执行到一半挂了,下次用同一个thread_id就能从断点继续。这在长任务场景里几乎是救命功能,也给“Agent 任务可以恢复”这个能力打了个底。
幂等控制也要提前想清楚。Agent 调用内部工具时,如果第一次调用成功写入了数据,但因为网络问题超时重试,就有可能导致重复下单或重复扣减。我建议给每次工具调用带上request_id,在工具函数内部用 Redis 的SET NX实现去重,同一个request_id只执行一次,这样重试就不会造成副作用。
5. AI Agent 扛并发:从 token 开销到推理调度的治理经验
隔离内网项目上线后,最头疼的问题往往不再是“能不能跑通”,而是“并发一上来怎么不崩”。AI Agent 的并发和普通 Web 服务完全是两码事,因为每个请求都要消耗 GPU 算力和 token 配额。这一节我不会只讲原理,会把账算给你看。
5.1 先算清并发账:QPS、显存与 token 吞吐
先说两个概念:QPS(每秒查询数)和并发连接数。比如同时有 20 个用户正在和 Agent 对话,这叫 20 并发,不代表 QPS 是 20,因为每个用户的请求可能持续几十秒。假设一个用户平均每 10 秒发一条消息,20 个并发用户的真实 QPS 大约是 2(20 / 10)。
但 Agent 场景特殊,模型生成是流式的、耗时的。我们算一下算力账:真 2 QPS,每个请求平均输出 500 token,那么模型在生成阶段每秒要产出约 1000 token(2 * 500)。而一块 4090 用 vLLM 跑 7B FP16 模型,稳定输出速度大约在 1000-2000 token/s 这个区间。也就是说,光这 2 QPS 就把一块 4090 的生成吞吐吃掉了接近一半,还没算输入 prefill 的消耗。
这个账算清楚之后,你对“需要几块卡”就会非常敏感。如果预算有限、显卡只有一块 4090,那么业务层必须限制同时进行推理的请求数,否则 GPU 显存被打满、排队堆积,最终所有请求全部超时。
5.2 三层限流设计:网关、应用、推理
我设计了三个层次的限流,每层管不同的维度:
- 网关层(Nginx):按用户或 IP 做粗粒度限流,防止突发流量击穿到后端。比如限制每个用户每分钟最多 30 次请求。
- 应用层(FastAPI):用信号量或令牌桶限制同时进入 Agent 编排的请求数。我会把并发上限设成一个可配置的
MAX_CONCURRENT_AGENTS,比如 4,超出直接返回 429。 - 推理层(vLLM):通过启动参数限制模型内部同时处理的序列数,比如
--max-num-seqs 4和--max-num-batched-tokens 4096,防止单个请求把整张卡的显存和算力占满。
下面是一个信号量的简单示例:
import asyncio from fastapi import HTTPException agent_semaphore = asyncio.Semaphore(4) # 同时最多跑 4 个 Agent 任务 @app.post("/chat") async def chat(request: dict): if agent_semaphore.locked() and agent_semaphore._value == 0: raise HTTPException(status_code=429, detail="系统繁忙,请稍后重试") async with agent_semaphore: return await run_agent(request)在隔离内网里,用户量本身可控,宁可让请求排队也不要让 GPU 崩溃。你要接受一个现实:同时 50 个人在用 Agent,不代表能让 50 个请求同时进入推理阶段,真实的并发上限由 GPU 吞吐决定。
5.3 token 到底是什么,以及怎么管住它
对于刚接触 Agent 的开发者来说,token 的含义和治理逻辑必须搞清楚。token 是模型处理文本的最小单位,不是字母也不是完整单词,而是模型分词器切出来的片段。一个 token 大概对应 0.75 个英文单词,或者 1 到 1.5 个汉字。“你好”这个词,在某些分词器里可能是一个 token,也可能是两个 token,具体要看模型的 tokenizer。
Agent 场景下 token 消耗比普通对话惊人得多,因为每一轮工具调用都要把“系统提示词 + 历史消息 + 工具返回结果 + 模型输出”全部拼在一起再发给模型。假设一个 Agent 任务内部调用了 3 次工具,每次工具返回 500 token 的结果,那么除了最终输出,额外消耗的 token 可能就是几千。如果并发再上来了,token 就是 GPU 算力的直接量化表现。
我的治理手段有四个:第一,设置单轮输出的max_tokens上限,比如 512,避免个别长输出把队列堵住;第二,启用上下文截断,超过窗口长度时只保留最近的 N 条历史消息,更早的内容用摘要代替;第三,利用 LangGraph 的 token 审计接口,把每轮对话消耗的 token 数记录到日志;第四,做一个 token 成本看板,让运维能直观看到当天 GPU 算力消耗是否合理。隔离网里虽然没有线上账单,但 token 消耗直接关系到卡够不够用。
5.4 异步编排与批量推理,把 GPU 吞吐吃满
很多 Agent 服务并发上不去,不是因为模型太慢,而是因为代码写得同步阻塞。FastAPI 里的async def只保证 IO 不阻塞事件循环,但如果你在请求里await一个 LangGraph 的同步执行(比如直接调graph.invoke),整个过程依然会把事件循环占死。正确的做法是把 Agent 编排从请求链路里拆出去。
我的落地方式是:
- FastAPI 收到请求后,把任务写入 Redis Stream(或者 Celery 队列),立刻返回“任务已接收”;
- 独立的 worker 进程消费队列,真正执行 LangGraph 图;
- worker 执行完把结果写回 Redis,前端通过轮询或 WebSocket 获取最终结果。
这套异步队列的收益很明显:API 服务本身不再参与长任务,所以不会被打满,横向扩容只需要加 worker 即可。推理层 vLLM 同时具备 continuous batching 能力,多个请求的 token 会自动拼批处理,GPU 的利用率比单路串行高得多。但我还是要强调,生产环境里max-num-seqs和max-num-batched-tokens不是越大越好,开太大反而会让每个请求的等待时间变长,需要实测调参。
6. 实战排雷:隔离内网里的高频问题与避坑经验
最后这部分全是血泪经验。我把在项目里遇到过的典型问题整理成一个速查表,再挑几个印象最深的展开说说,希望能帮你绕开这些坑。
6.1 高频问题速查表
| 现象 | 排查思路 | 解决办法 |
|---|---|---|
| 模型加载失败 | 文件路径、文件完整性、显存不足 | 核对 SHA256,检查 GPU 显存,量化模型或降级 |
| 首次请求卡顿几十秒 | 模型 prefill 冷启动慢,权重加载在初始化 | 启动后写预热脚本,提前调用一次完整 Agent 流程 |
| 并发一高就全部超时 | 推理层队列满,或应用层没限流 | 加三层限流,控制同时进入推理的请求数 |
| 工具调用报“工具未注册” | 模型返回的工具名和注册不一致 | 入口处做 name 归一化处理 |
| 模型生成到一半断开 | 最大 token 限制或上下文超长 | 检查 max_tokens 设置,增加上下文截断策略 |
| 容器启动后找不到模型文件 | 挂载路径错误 | 用docker inspect检查挂载,确认路径映射 |
6.2 最让我头疼的五个坑
第一个坑是模型路径带空格和中文。内网机器的目录命名常常比较随意,模型挂载路径里带了个空格,vLLM 加载时读取配置文件路径解析失败,报了一个很不直观的 JSON 解析错误。排查了一个下午才发现是路径问题。这类根因最后都指向一个原则:所有路径统一用绝对路径,且避免空格和特殊字符。
第二个坑是 CUDA 和 torch 版本不匹配。离线环境最大的问题是没法随时换版本。我有一次在打包时用了 torch 2.3,但内网机器的 driver 版本对应的 CUDA runtime 是 12.0,而 torch 2.3 默认的 CUDA 版本是 12.1,虽然看起来接近,但部分算子就是跑不了。最终只能回外网打包了一份匹配 CUDA 12.0 的 torch,再重新走一遍物料审批流程。这个教训总结成一句话:打包前先把nvidia-smi和python -c "import torch; print(torch.version.cuda)"的版本输出写到交付文档里,两边必须一致。
第三个坑是内网环境的 Embedding 模型从公网下载。RAG 场景里需要 embedding 模型把文本向量化,结果部署脚本里写的是sentence-transformers的默认模型名,Agent 应用在内网启动时没有配HF_HUB_OFFLINE=1,导致每次启动都去尝试访问 HuggingFace,然后把启动流程拖慢甚至卡死。这个坑提醒我:所有涉及模型加载的代码,必须在启动时就把离线环境变量显式设置好,不能被默认行为带着走。
第四个坑是 max_tokens 设置过大导致 OOM。我最初为了让 Agent 的回答更完整,把max_tokens设置成了 2048。结果并发上来之后,几个请求同时都在长输出,GPU 显存瞬间被打爆,甚至把 vLLM 进程都搞崩了。后来我把默认值降到了 512,大输出单独在 Prompt 里引导分步输出,整体稳定性立刻上来了。
第五个坑是并发请求里工具调用时序错乱。两个会话同时调同一个内部工具,但因为共享了一个 request_id 生成器,出现 ID 冲突,导致两个工具调用互相覆盖结果。后来改成 UUID 并且全链路跟踪同一个trace_id,问题才根治。日志里没有统一的 trace_id 就排查这类问题会非常痛苦,所以从第一天就要把 trace_id 透传到所有调用链上。
6.3 隔离内网部署的一点点私藏技巧
最后分享几个我觉得非常实用的小技巧,都是实际操作中摸出来的。
第一,强烈建议把“启动自检”写成一个脚本,每次部署完自动检查模型文件完整性、依赖版本、GPU 驱动、端口占用、离线环境变量。有了这个脚本,新机器或者故障恢复的时候,从原来的“手动排查两小时”降到“一条命令跑完三十秒见结果”。
第二,内网里要有一个“影子系统”。我指的是给 Agent 准备一套只读的测试知识库和模拟工具环境,专门用来做回归测试。因为内网环境不能随便改配置重启,有了一套影子系统,你可以在不影响生产的前提下验证模型更新、Prompt 修改、工具变更。没有这套系统的话,改任何东西都像是在高空走钢丝。
第三,模型输出不要直接信任。即使是大模型,在工具调用场景下也可能输出幻觉参数,比如订单号根本不存在却伪造了一个。我的做法是:在工具节点里加入“参数强校验”,一律用正则或数据库校验,不存在的 ID 直接返回错误信息给模型,让它重新推理。这一步虽然简单,但能显著提高 Agent 的稳定性和可信度。
工具调用流程如果能沉淀出一套“标准操作规范”,配合影子系统做回归,项目越到后面越稳。隔离内网环境特殊,天然不适合频繁试错,所以每一次变更都要有预案、有回滚路径、有验证步骤,这也是我觉得整个工程里最值钱的经验。