☰
隔离内网AI Agent部署实战:离线模型选型、依赖打包与性能调优
2026/10/8 15:41:47 网站建设 项目流程

隔离内网的AI Agent部署,跟你在开发机上跑通一个demo完全是两码事。开发环境里pip install一下、联网拉个模型权重、调几个API,Agent就能跑起来;可一旦目标环境是那种没有外网、数据不能出域的机房,整个链路的复杂度会翻好几倍。我最近一个项目就是这样:客户的生产网与办公网物理隔离,但业务方又明确要求上Agent做运维工单自动分类、内部知识库问答和巡检报告生成。这篇文章就把这段工程实战完整记录下来,从模型选型、依赖离线打包、Agent编排到最后的性能调优,适合正在做私有化、信创适配或者干脆就是内网环境落地的团队参考。

1. 项目背景与离线选型思路

1.1 这种项目到底卡在哪

先说清楚"隔离内网"这个前提。客户机房分生产网和管理网,中间有防火墙策略,研发环境能访问的只有几台跳板机,外网是完全不通的。业务方提的需求听起来很常规:工单系统里来了新的故障单,Agent要能自动判断类型、给出答复草稿;内部几千份制度文档要做成问答;每天还要定时抓取监控数据生成巡检摘要。这些需求在公网环境用现成API很容易做,但在这里全被网络环境卡死了。

具体有三大约束必须面对:

  • 网络约束:外部模型API、Hugging Face、pip源、Docker Hub全部不可达,所有软件资产必须在入场前准备好。
  • 数据约束:业务数据不外传,连模型日志都不能投递到外部服务,所有推理必须本地完成。
  • 资源约束:目标服务器只有一张N卡(24GB显存),CPU内存也不宽裕,模型规模不能拍脑袋选。

为什么要用Agent而不是传统规则引擎?规则引擎在这个场景下其实也做了一阵子,但工单类型和文档内容更新太快,写规则的成本远高于训练语料维护。Agent的价值在于它能把"理解需求、调工具、组织回答"这几件事串起来,业务人员只需要用自然语言描述诉求,剩下的是模型和编排框架的事。

1.2 选型决策:哪些组件必须离线化

这个项目的选型逻辑其实可以总结成一句话:能不自研的不自研,能离线化的全部离线化。下面是最终敲定的清单,也是我在类似项目里反复验证过的一套组合。

组件选型选型理由
大语言模型Qwen2.5-7B-Instruct / Qwen2.5-14B中文能力强,Function Calling有专项训练,量化方案成熟
推理框架Ollama + GGUF / vLLM前者部署轻量适合CPU或单卡,后者高吞吐适合生产
Embedding模型bge-large-zh-v1.5中文语义召回稳定,参数规模适中
Agent编排LangGraph + 自研工具层图式状态管理适合可控流程,避免全自动发散
向量库Chroma(试点)/ Milvus(生产)支持离线部署,数据落本地磁盘
依赖管理私有PyPI(devpi)+ Harbor镜像仓库解决内网安装依赖的根本问题
前端入口内网Web服务(FastAPI + 简单页面)Agent能力以服务形式暴露,不做重UI

模型这层是重点。Qwen2.5系列是当前离线部署性价比不错的选择,7B版本用Q4_K_M量化后权重才4.4GB左右,一张24GB的卡能轻松加载;14B量化后大约9GB,单卡也能跑,但推理延迟会明显上升。我们的经验是:如果Agent的主要任务是工单分类、文档问答这类短文本场景,7B足够;如果要生成巡检报告这类长文本,14B质量更好,但要在批处理和并发上做取舍。

1.3 整体架构怎么组织

整个系统的链路是这样的:内网用户通过浏览器访问Agent Web服务,请求先到FastAPI网关,网关把用户意图交给LangGraph编排的Agent流程。Agent内部有一个规划模块负责拆解任务,一个工具调用层负责实际执行操作(查工单、读知识库、调内部API),所有需要模型理解的地方都走内网推理服务,最终把结果组织成回答返回给用户。

这个架构看起来跟公网的Agent没什么两样,但关键差别在于每一个节点都必须能在断网环境下独立运行。模型推理、向量检索、工具调用、日志存储,全部本地化。另外,Agent流程被刻意设计成"先规划后执行、逐步校验"的节奏,而不是一次性让模型自由发挥。原因后面会细说,在内网环境里可控性比"聪明"重要得多。

2. 离线环境准备:模型、依赖与镜像打包

2.1 模型资产怎么安全运进内网

很多团队第一步就翻车,以为模型文件拷个移动硬盘带进去就行了。实际操作要严谨得多:先在带外网络下载模型,校验哈希,再通过介质拷贝,进内网后重新校验一次,确保模型权重没有被篡改或损坏。

以Ollama方式为例,外网机器上先拉取GGUF格式:

# 外网下载,注意记录文件哈希 huggingface-cli download Qwen/Qwen2.5-7B-Instruct-GGUF \ --local-dir ./qwen2.5-7b-gguf \ --include "Q4_K_M/*" sha256sum ./qwen2.5-7b-gguf/Q4_K_M/*.gguf > sha256.txt

把文件拷贝进内网机器后,用Modelfile定义对话模板和参数,再导入Ollama:

# Modelfile 内容 FROM ./qwen2.5-7b-q4_k_m.gguf TEMPLATE """{{ if .System }}<|im_start|>system {{ .System }}<|im_end|> {{ end }}<|im_start|>user {{ .Prompt }}<|im_end|> <|im_start|>assistant """ PARAMETER temperature 0.7 PARAMETER top_p 0.8 PARAMETER num_ctx 8192 # 创建并运行 ollama create qwen2.5-7b -f Modelfile ollama run qwen2.5-7b

如果你走vLLM路线,下载的是safetensors格式的原始权重,同样先校验再拷贝。vLLM对模型格式要求更严格,Qwen2.5的AWQ量化版本可以直接加载,命令大概是:

python -m vllm.entrypoints.openai.api_server \ --model /data/models/Qwen2.5-7B-Instruct-AWQ \ --quantization awq \ --max-model-len 8192 \ --host 0.0.0.0 \ --port 8000

这里有个经验:模型路径、量化格式、上下文长度这些参数一定要先在内网测试机验证一遍再批量部署。我遇到过最典型的坑就是AWQ量化版本在vLLM低版本上不兼容,换一个镜像后正常,而这些排查在内网里成本很高,提前验证能省出大量时间。

2.2 Python依赖打包与私有PyPI搭建

依赖是另一个大坑。Agent项目至少有几十个Python包:LangGraph、Chroma、FastAPI、Pydantic、各种客户端SDK。在内网直接pip install是行不通的,必须在带外环境把依赖全部下载好。

最简单的方法是用pip download把整个依赖树拉下来:

pip download -r requirements.txt \ -d ./offline-packages \ --platform manylinux2014_x86_64 \ --python-version 3.10 \ --only-binary=:all:

然后把offline-packages目录拷贝进内网,离线安装:

pip install --no-index \ --find-links=./offline-packages \ -r requirements.txt

如果项目会长期迭代,建议直接在内网搭一个devpi私有PyPI服务,把所有轮子包上传进去,让内网开发机把index-url指向devpi。这样后续加依赖不用每次都折腾"拷贝-安装"流程。

这里必须提醒一句:pip download时如果缺了--only-binary=:all:,可能会下载源码包,进内网后编译失败(因为缺编译工具链),所以下载阶段最好优先找轮子包。另外,不同机器glibc版本不一样,建议在跟目标机一致的CentOS/麒麟容器里做打包,否则二进制包装不上。

2.3 Docker镜像的离线搬运

如果目标环境允许用Docker,那镜像搬运是最省事的。做法很简单:外网机器拉镜像,打包成tar,拷贝进内网load,再推送到内网Harbor仓库。

# 外网机器 docker pull ollama/ollama docker save ollama/ollama | gzip > ollama.tar.gz # 内网机器 docker load -i ollama.tar.gz docker tag ollama/ollama registry.internal:8443/public/ollama docker push registry.internal:8443/public/ollama

Harbor的好处是让多台内网节点都能统一拉镜像,不用每台机器手动load。这里有个细节:内网很多机器没有配置DNS,docker pull时如果域名解析不了,直接改镜像源地址为IP:端口的方式最保险。还有,如果项目涉及GPU推理,记得要拉带CUDA的镜像(比如ollama/ollama:latest对应CUDA 12),并且在宿主机装好NVIDIA Container Toolkit,否则容器里看不到显卡。

3. Agent链路设计与核心实现

3.1 规划模块:任务拆解怎么做得可控

Agent的规划模块是"大脑",负责把用户的一句话目标拆解成可执行步骤。市面上的方案大致两类:一类是ReAct风格的循环,让模型不断交替思考和行动直到任务完成;另一类是Plan-and-Execute,先一次性生成完整计划,再按计划逐步执行。

我们在内网场景选择了Plan-and-Execute的变体:先让模型生成结构化计划,每执行一步后再根据结果决定是继续、重试还是调整计划。原因也简单——内网工具操作通常是有状态的(比如查工单、改配置),如果模型思维发散随意调用,很容易把只读操作变成误操作。计划先行,至少给执行的每一步留了一道人工可审计的关卡。

计划本身必须严格结构化。我们让模型输出JSON,再用Pydantic做schema校验,防止模型输出一堆乱七八糟的文本:

from pydantic import BaseModel, Field class AgentStep(BaseModel): step_id: str = Field(description="步骤唯一标识") action: str = Field(description="要调用的工具名") params: dict = Field(description="工具参数字典") reasoning: str = Field(description="为什么执行这步,用于审计") class AgentPlan(BaseModel): goal: str = Field(description="用户目标") steps: list[AgentStep] = Field(description="步骤列表")

这种结构化的好处有两个。第一,模型即使生成了幻觉步骤,也能在JSON解析阶段被拦截或者告警,不会直接落到工具执行;第二,审计日志里可以清晰看到每一步的推理依据,这在政企环境非常重要。

3.2 工具调用层:Function Calling的落地难点

工具调用是Agent真正"动手"的环节。Qwen2.5系列的Function Calling能力在线下评测里表现不错,它会在推理时输出符合JSON格式的工具调用信息。但我们实际部署时发现,纯靠模型自由发挥并不稳定,常见问题包括:参数名写错、类型对不上、该调工具的时候不调。

解决办法是加一道"约束输出"层。推荐用outlines或者llama.cpp的JSON schema约束,让模型生成过程直接受限于工具定义的JSON结构。效果很直观:之前工具调用格式错误率可能有个位数百分比,加上约束后基本能压到千分之一以下。

工具注册层的代码大致长这样:

from langchain_core.tools import tool @tool def query_ticket(ticket_id: str) -> dict: """根据工单号查询工单系统详情,返回状态、优先级、处理人等字段""" # 实际走内网工单API return {"ticket_id": ticket_id, "status": "open", "priority": "high"} @tool def search_knowledge(query: str) -> list[str]: """在内部知识库中检索与query相关的文档片段""" return knowledge_base.search(query, top_k=5) tools = [query_ticket, search_knowledge]

工具执行要特别注意权限隔离。Agent能调的工具越多,攻击面越大。我们做了三件很务实的事:第一,所有工具只走只读接口,写操作必须走单独审批流程;第二,每个工具调用前要校验参数白名单(比如ticket_id必须匹配工单号格式,避免SQL注入路径);第三,给工具执行加超时和重试上限,防止某个内部接口卡死拖垮整个Agent链。

3.3 记忆与RAG:让Agent"记得住"上下文

Agent如果没有记忆,每次对话都是"失忆"状态,这在文档问答场景里体验很差。我们做了两层记忆:短期记忆用会话窗口加摘要压缩,长期记忆交给向量库。

短期记忆这块,直接做法是把最近几轮对话拼进prompt,但token有限,对话一长就会爆。我们用了摘要压缩:当历史对话超过某个阈值,让模型把前面的内容浓缩成一段摘要,替代原始轮次。这个方案实现简单,效果也稳定。

长期记忆就是RAG。内网几千份制度文档,先做文档解析、分块、向量化:

# 文档分块 + 向量化入库 python ingest.py \ --source /data/internal_docs \ --chunk_size 512 \ --chunk_overlap 64 \ --embedding_model bge-large-zh-v1.5 \ --vector_store chroma

分块大小是调出来的。512字符在中文场景下比较合适,太长召回精度下降,太短上下文碎片多、检索噪音大。重叠64字符是为了保住跨块语义。

embedding模型必须跟检索阶段用同一个,而且版本不能乱换。我们有个选择题吃过大亏:测试环境用了一个更新版本的embedding模型,向量维度变了,结果线上检索全部为空。这类问题在离线环境很难发现,因为日志不会报错,只有检索结果为空。所以embedding模型的版本管理要像管理模型权重一样严格。

4. 实操记录:从模型服务到Agent编排落地

4.1 模型服务启动与验证

先用Ollama跑通单机推理。启动服务时注意几个环境变量:

export OLLAMA_HOST=0.0.0.0 export OLLAMA_KEEP_ALIVE=30m export OLLAMA_NUM_PARALLEL=4 ollama serve

OLLAMA_KEEP_ALIVE控制模型在内存里的驻留时间,设太短会导致频繁加载模型(加载一次7B模型大概耗时几十秒),设太长又占显存;OLLAMA_NUM_PARALLEL控制并行请求数,内网这种几十人并发的小规模场景设4就够了,太高会显著拉长每个请求的延迟。

启动后用curl验证推理服务是否正常:

curl http://localhost:11434/api/generate \ -H "Content-Type: application/json" \ -d '{"model":"qwen2.5-7b","prompt":"你好,介绍一下你自己","stream":false}'

第一次请求会慢一些,因为要加载模型,之后就在驻留内存里了。如果这一步验证通过,后面Agent链路就稳了一大半。

接着建议做一次"工具调用格式"的专项测试。给模型一条需要调工具的指令,比如"查询工单TKT-20251201的状态",看它输出的JSON是否符合我们的schema。这一步测试价值极高,因为很多模型自由输出时参数名会漂移,提前发现就能早做约束。

4.2 Agent编排:LangGraph节点实现

我们在LangGraph里定义了一条带循环的图:单轮请求进来,先过"意图识别",再走"规划",规划结果如果是需要调工具的,进入"工具执行",执行完交给"回答生成",同时判断是否需要再次规划,直到终态。

核心代码结构大致是这样的:

from langgraph.graph import StateGraph, END from typing import TypedDict class AgentState(TypedDict): user_input: str plan: dict tool_results: list[dict] final_answer: str def route_step(state: AgentState) -> str: if state["plan"].get("steps"): return "execute_tools" return "generate_answer" builder = StateGraph(AgentState) builder.add_node("intent", intent_recognition) builder.add_node("plan", plan_generation) builder.add_node("execute_tools", tool_executor) builder.add_node("generate_answer", answer_generation) builder.add_edge("intent", "plan") builder.add_edge("plan", "execute_tools", condition=route_step) builder.add_edge("execute_tools", "generate_answer") builder.add_edge("generate_answer", END) app = builder.compile()

这个图看起来简单,但内部逻辑比demo要厚实。比如tool_executor节点会先解析计划中的工具名、校验参数、执行工具、把结果挂载到状态里;generate_answer节点会把用户输入、工具结果、历史记忆拼成最终prompt,要求模型只基于工具结果回答,不许编造。如果工具结果为空,模型必须明确回复"未能查到相关信息",而不是自己编一段。

4.3 业务系统对接与权限控制

Agent要真正干活,就得接内部系统。我们的做法是:工单系统有现成的内网API网关,Agent工具层只需要调网关暴露的只读接口。凭证管理这块要特别小心,不能把API Token直接拼进prompt传给模型,而是放在工具执行环境的环境变量里,由工具函数自己读取。

# 工具执行环境的凭证配置 export TICKET_API_BASE=http://ticket-api.internal:8080 export TICKET_API_TOKEN=$(cat /etc/agent-secrets/ticket.token) export KNOWLEDGE_API_BASE=http://kb-api.internal:9090

为什么不能把Token放进prompt?因为模型输出的内容会被记录进日志和审计系统,Token一旦出现在日志里就等于泄露了。即使模型不会主动说,这个风险也不值得冒。另一个点是所有工具调用都要打审计日志,包含调用用户、工具名、参数、结果摘要、耗时,这样后续出了问题能回溯是哪个环节的责任。

4.4 知识库灌库的流程细节

知识库灌库是个脏活累活,但直接决定RAG效果。我们的灌库流程分四步:文档收集清洗、格式解析、分块、向量化入库。

素材很多是Word和PDF,解析阶段直接用unstructured库处理,但要注意表格内容,拿来即用效果很差,需要保留表格结构调整分块策略。分块用RecursiveCharacterTextSplitter按语义切,每块512字符重叠64。向量化用bge-large-zh-v1.5,基于SentenceTransformer接口,最后写入Chroma的持久化目录。

灌库完成之后一定要做检索质量抽检。我习惯的方式是:挑10个业务高频问题,逐个跑检索,人工判断召回的前5个片段是否真的跟问题相关。如果发现召回结果质量差,优先调整分块策略,而不是盲目换embedding模型——很多团队一上来就换更重的模型,结果检索没变好,推理负载倒是上去了。

5. 性能调优与安全加固细节

5.1 推理性能调优的四个抓手

隔离内网环境下没有"买更高配置"这种选项,所有调优只能在现有硬件上抠。我们主要抓了四块。

第一是量化选择。7B模型用Q4_K_M和Q8_0之间差了大概一倍显存,但回答质量差异不超过5%。内网这种以任务执行为主的场景,Q4_K_M完全够用;14B如果显存吃紧,优先用AWQ或GPTQ的4bit量化版本。

第二是并发参数。Ollama的OLLAMA_NUM_PARALLEL和vLLM的--max-num-seqs要按业务峰值设置。我们实测:7B Q4模型在24GB卡上并行4个请求,平均延迟从单请求的500ms涨到900ms左右,但吞吐提升了3倍多。如果并行太高(比如8个),显存溢出直接OOM,进程崩掉,整个Agent服务不可用。宁可牺牲一点并发,也要保住可用性。

第三是流式输出。Agent回答生成阶段,一次性返回完整结果会让用户等待很久。FastAPI直接用了StreamingResponse,模型输出按token分片推送,用户体验提升立竿见影。实现不复杂,就是把Ollama的流式响应转成SSE输出。

第四是冷启动预热。早晚高峰用户进来之前,脚本先发几个请求把模型预热到驻留状态,避免第一个人吃满模型加载的时间。我们用了一个最简单的定时任务,每天8点先跑一次简单的问答请求,实测下来首个请求的P99从15秒降到了2秒以内。

5.2 内网环境的安全加固

安全这块经常被忽略,因为"内网隔离"给了人一种虚假的安全感。但内网不代表没人攻击,运维误操作和数据泄露同样会发生。我们做了四件事:

第一,模型输入过滤。所有进入Agent的用户输入先过一个敏感词过滤器和长度限制器,防止投喂超长文本触发prompt注入。第二,工具执行权限最小化。数据库查询只用只读账号,SSH类工具直接禁止启用,文件操作限定在指定临时目录。第三,输出脱敏。模型回答里如果出现手机号、身份证号这类PII信息,先过正则脱敏再展示给用户。第四,审计日志。所有Agent交互、工具调用、错误信息都要落日志,日志本身要有访问权限控制。

特别强调一点:模型本身可能是"不可信"的。即使是内网部署的开源模型,也可能被诱导输出敏感指令。所以Agent的权限边界必须在框架层兜底,不能依赖模型的"自觉"。

6. 高频问题与排障实录

6.1 故障速查表

这里整理一份我们项目踩过的典型问题速查表,按频率排序:

问题现象可能原因排查与解决
模型加载失败或OOM显存不足、量化文件格式错误降低量化级别、减少并行数、检查GGUF文件哈希
工具调用输出无法解析模型自由生成JSON格式漂移加JSON schema约束输出、做一次格式校验重试
知识库检索结果为空embedding模型版本不一致或维度不同统一embedding模型版本、检查向量库collection维度
Agent回答跟工具结果无关上下文窗口溢出、历史被截断压缩历史、减少不相关工具结果、增大num_ctx
请求超时模型冷启动、并发过高预热模型、调整并行数、开启流式输出
容器内看不到GPU未安装NVIDIA Container Toolkit安装nvidia-driver、配置容器GPU参数
内网镜像拉取失败DNS解析不了域名镜像地址改用IP:端口,或配置hosts
中文回答乱码或模板错误Modelfile里对话模板设置不对检查模型的chat template,用官方模板补齐

这张表不是一次性生成的,前三条我们是真的逐一踩过。特别是"Agent回答跟工具结果无关"这个问题,排查了很久,最后发现是历史对话太长把工具结果挤出了模型上下文窗口。后来把所有工具结果的摘要化处理做了,问题才消失。

6.2 三个特别值得提醒的坑

第一个坑是上下文窗口的"隐形溢出"。Agent在连续对话中,历史记录、工具结果、知识库片段都会挤占上下文窗口。如果模型是7B版本且num_ctx只有8192,一轮工具调用可能就吃掉3000多个token,加上历史,几轮之后上下文就满了。症状是Agent开始"失忆"——用户前面说过的话它记不住,工具结果也回答不到点上。解决办法是给历史做摘要压缩,以及控制单次知识库检索返回的片段数量和长度。

第二个坑是工具调用输出格式的"幻觉"。明明工具只有三个参数,模型却自己编出第四个参数。约束输出是正解,但内网环境还要注意一个问题:如果用的是Ollama而不是vLLM,约束输出库的兼容性要提前验证,有些库对Ollama的接口支持不到位,装上才发现跑不起来。

第三个坑是embedding模型的版本漂移。我们当时为了让检索效果"更好",升级了embedding模型版本,结果新的向量维度跟库里已有的向量不一致,所有新增文档无法写入。最终只能全量重灌。这个教训告诉我们:离线环境下,embedding模型的版本必须纳入变更管理,升级前先备份向量库,最好先做小范围内的重灌对比,再决定是否全量替换。

6.3 内网Agent项目后续还能怎么扩展

这个项目跑稳定之后,我们其实已经在想下一步了。比如给Agent加上定时调度能力,让它在每天早上自动巡检业务系统的健康状态,发现异常自动生成工单草稿;再比如让Agent对接内部的报表平台,直接按用户要求拉数据生成分析摘要。隔离内网环境有一个隐形优势:因为没有任何外部依赖,整个系统反而是100%自主可控的,后续想加什么能力,只要能准备好离线资产,就可以往里加。

如果你也在做类似项目,我的建议很简单:先花两到三周把离线资产链路打磨顺——模型、依赖、镜像、embedding这些基础一定要一次到位,否则后期每个小改动都要重复"拷贝-安装-验证"的循环,非常消耗士气。Agent编排本身反而是相对容易的部分,框架都成熟了,难的是把它放进一个受限环境里,并且保证它可靠、可审计、可回退。

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

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

立即咨询