1. 写在前面:为什么要在隔离内网里跑 Agent
年初我接手了一个数据合规要求极高的项目,客户网络环境是物理隔离的内网,业务系统之间走区隔好的专网,终端侧基本不暴露公网出口。在这种环境里,大家最自然的第一反应是“先别谈智能,能跑起来再说”。但你真把一个 LLM API 接到业务系统里,安全评审基本不会给你过。因为数据出不了网,模型也不能在公网上调。于是“隔离内网下的 AI Agent 工程实战”就成了一个绕不开的话题。
我这边最初的目标其实挺朴素:让业务人员用自然语言去查内网数据库、调用内部接口、生成分析摘要。说白了,就是把一个 Agent 部署在内网里,它没有外网依赖,所有模型推理、知识检索、工具执行全在本地完成。这里最核心的三个词是:本地模型、离线依赖、受控工具。如果这三个你都能搞定,Agent 才能算是在内网扎下根,否则就是写了个只能跑 demo 的玩具。
这篇文章不打算讲太空的架构图,也不会上来就让你堆一堆组件。我会从“最小可运行闭环”出发,把模型层、Agent 循环、记忆工具、内网部署坑位按实战顺序拆开。适合谁看?如果你正准备在内网环境里落地 Agent,或者已经被离线依赖折腾得想摔键盘,那这篇能帮你少走大半个月弯路。
2. 整体架构设计与组件选型
2.1 先盘点内网 Agent 需要哪些东西
很多人一开始就把 Agent 想成“一个牛逼的模型”,其实模型只是其中一个环节。真正要在隔离内网里跑业务,你至少需要四层东西。
第一层是模型推理层。模型文件必须提前拷进内网,推理服务要么是纯 CPU 跑量化小模型,要么是有 GPU 时跑中等规模模型。这一层的核心指标是推理速度和显存占用,而不是参数量越大越好。
第二层是 Agent 框架层。这里包含你的 Agent 主循环、提示词管理、工具调用协议、任务规划逻辑。你可以自研一个几百行的循环,也可以引一个离线版框架,但前提是它的依赖必须能全部从内网源装好。
第三层是记忆与知识层。内网环境里最值钱的往往就是那一堆本地文档和数据字典,所以 Embedding 模型、向量库、检索组件是刚需。要注意,Embedding 模型同样需要离线部署,不能偷偷去公网拉接口。
第四层是工具执行与安全层。Agent 要能调用内部 API、SQL、文件读取,但不能是个什么都敢跑的野马。得有白名单校验、超时控制、操作审计。很多生产事故不是模型不行,而是工具权限没管住。
这四层每一层都有大量细节,但选型上我建议克制。能用静态编译的二进制就别用重依赖框架,能用一个轻量 Python 包就别引整个 Java 微服务。内网环境最难的不是功能实现,是你根本没法轻易重新拉依赖。
2.2 模型层:离线推理引擎怎么选
模型层我试过几套方案,第一套是直接用 Ollama。它确实方便,模型管理命令做得不错,但在完全隔离的内网里,你需要把安装包和模型文件一起拷进去,版本也要严格对上。另一套是 vLLM,吞吐高但依赖 CUDA 环境复杂,离线安装一堆编译依赖很容易心态爆炸。最后我选了 llama.cpp,准确说是基于 llama.cpp 的 server 模式。它的优势很现实:
- 编译产物是纯二进制,基本不依赖系统 Python 环境,glibc 版本对了就能跑。
- GGUF 模型文件可以提前准备好,内网拷贝即用,不需要额外格式转换。
- 自带 OpenAI 兼容的 HTTP API,Agent 端只需要改 base_url,调用体验和云上 API 几乎一样。
- 支持 CPU 和 GPU 跑量化模型,即使是低配机器也能用小模型先跑通流程。
另外如果你团队里有 Rust 背景,可以关注一下 candle 和 mistral.rs 这类 Rust 生态的推理引擎。candle 是 HuggingFace 出的,模型加载逻辑和推理过程都由 Rust 实现,部署形态非常干净,对嵌入式服务和边缘侧比较友好。但说实话,它目前的生态成熟度还比不上 llama.cpp,社区交流和踩坑案例少,所以我只在原型阶段用过,生产还是保守选择 llama.cpp。
选完引擎之后就是模型文件的选择。内网环境普遍 GPU 资源有限,我这边用的是 Qwen2.5-7B-Instruct 的量化版,几个 GB 的模型文件,一张 24G 显卡还能同时跑 Embedding 模型。如果是纯 CPU 环境,就会用小一点的 Qwen2.5-3B,或者 internlm 的小尺寸模型。这个东西没有绝对答案,但我的经验是:先跑通最小的,再逐步升级。
2.3 Agent 框架层:不迷信 LangChain,搭一个轻量内核
最开始有同事建议直接用 LangChain,图表和工具多,社区案例也多。但真在隔离内网里一装,问题就出来了。LangChain 的依赖树太深,光一个离线安装就能折腾好几轮,而且这个包更新速度极快,内网里的版本基本跟不上,你搜到的文档和实际版本经常对不上。对我来说,Agent 核心循环本身并不复杂,无非是“思考 - 行动 - 观察 - 再思考”的循环。我花了一个下午,用 Python asyncio 写了一个不到 500 行的内核,反倒没有版本焦虑,逻辑完全可控。
具体来说,我的内核支持 ReAct 风格和 Plan-and-Execute 风格。ReAct 适合单步工具调用,比如查数据、调接口;Plan-and-Execute 适合拆解任务,比如“对比三个部门的报表并生成摘要”。自研内核的好处是,你可以在工具调度、上下文截断、错误恢复这些地方完全按业务需要去写逻辑,不被框架的抽象接口绑架。
当然,你完全可以用 LangChain 或者 LlamaIndex,我并不是否定它们,只是建议你在隔离内网里先做个“主流程自研、周边组件轻量化”的取舍。毕竟内网项目的最优解不是“功能最多”,而是“依赖最少,能快速排障”。
3. 落地实操:从零搭一个内网 Agent
3.1 离线环境的依赖包搬运方案
这一步看着简单,实际坑最多。在隔离内网里,你不能随时pip install requests,所以最保险的做法是在一台能联网的跳板机上,先把所需依赖完整拉下来,再往内网搬。重点是要用pip download而不是pip install,并且要把所有传递依赖都考虑进去。
我当时用的基本姿势是:
pip download -d /offline_packages \ -r requirements.txt \ --platform manylinux2014_x86_64 \ --python-version 312 \ --only-binary=:all:这样会把 requirements 里所有包和其依赖的 wheel 都下载到本地目录。到了内网之后,再执行:
pip install --no-index --find-links=/offline_packages -r requirements.txt这里有两个容易踩的坑。第一个,--only-binary=:all:意味着只下载 wheel 包,如果某个依赖没有发布对应的 wheel,就会失败。这时候你可以换个 tag 重新下,或者找一个替代包。第二个,不同 Python 版本兼容的 wheel 不同,一定要确认内网机器的 Python 小版本和下载环境一致。另外,如果是 CPU 和 GPU 混用的机器,像torch这种包还得区分+cpu和+cu121版本,否则一跑就报 CUDA 错误。
还有一类隐藏依赖是系统层面的,比如libgomp、libaio、ffmpeg动态库。Python 层面没问题了,启动服务时却可能因为缺.so文件直接崩。我的建议是在内网机器上先跑一个最小的 import 测试脚本,把所有核心包都 import 一遍,提前暴露缺库问题。
3.2 内网 LLM 服务封装与调用
模型推理服务的接口形式,强烈建议统一成 OpenAI 兼容格式。这样 Agent 层不必关心你后面是把 llama.cpp 换成了 vLLM,还是换了别家推理引擎,只要改一下 base_url 就行。
llama.cpp 的 server 模式启动命令类似:
./llama-server -m /models/qwen2.5-7b-instruct-q4_k_m.gguf \ --host 0.0.0.0 --port 8080 \ -c 8192 --n-gpu-layers 999启动之后可以看/health接口确认服务状态。Python 端直接使用openai库:
from openai import OpenAI client = OpenAI( base_url="http://192.168.10.10:8080/v1", api_key="local-not-needed", ) resp = client.chat.completions.create( model="local-qwen", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_query}, ], )注意这里并不需要一个真实的 API Key,随便填一个占位符就行,但代码里最好写到配置项里,方便适配将来接入统一网关。内网环境模型服务通常是供多个 Agent 共同调用,我建议在 llama.cpp 前面再加一层简单的连接池或请求队列,避免并发一上来就击穿推理服务。
3.3 Embedding 与私有知识库:让 Agent 记住内网文档
光有语言模型还不够,Agent 要真正回答业务问题,就得检索内网里的文档和数据字典。我这边安排了一个独立的 Embedding 服务,模型用的 BGE 系列(bge-large-zh),部署方式还是绑定到 llama.cpp 同类的推理引擎上,但走的是 Embedding 接口。由于是纯离线部署,模型文件从外部拷贝进去后不需要任何联网校验。
向量库我选了轻量的 Qdrant 容器版。虽然也有 Chroma 这类更轻的方案,但 Qdrant 的多租户和过滤条件支持更好,后续给不同部门做数据隔离更方便。在离线内网里,容器镜像直接 load 进去:
docker load -i qdrant-image.tar docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant索引之前,得先把文档切块。切块我一般用的是语义切块而不是固定大小,简单场景下固定 500 字重叠 100 字也凑合,但遇到表格和代码块就容易断裂。RAG 的质量很大程度上取决于切块质量,这块不要偷懒。
向量检索和 Agent 的衔接是 RAG 的关键。我的做法是:Agent 收到用户问题后,先重写成一个更适合作检索的关键词组合,再从向量库里拉 top-K 个相关片段,塞进上下文里。注意一定要把文档来源或标题带上,这样模型答完之后能给别人看引用来源,避免一问三不知还能一本正经地编。
3.4 工具层的核心:内网工具的注册与调用
Agent 的内网工具无外乎三类:查数据库、调内部 HTTP API、读文件或文档。最关键的设计是工具注册表,每一个工具都是一个函数加一份 JSON Schema 描述。模型看到的是描述,真正执行的是局域网内的受控服务。
我举一个例子,某个工具是“查询销售订单”,注册时的定义类似这样:
@agent_tool( name="query_sales_order", description="根据客户名称和日期区间查询销售订单列表", parameters={ "type": "object", "properties": { "customer_name": {"type": "string"}, "start_date": {"type": "string"}, "end_date": {"type": "string"}, }, "required": ["start_date", "end_date"], }, ) def query_sales_order(customer_name: str, start_date: str, end_date: str): # 内部执行 SQL 或调用数据服务 return sales_data注册表的核心价值在于:模型只需要学会怎么填参数,不需要知道 SQL 怎么写。你可以把写 SQL 的权限全部收拢到代码里,避免模型生成一些乱七八糟的动态 SQL 打到生产库上。这一点在隔离内网里尤为重要,因为数据安全是红线,不能为了“智能”就放开数据库权限。
同时,所有工具执行前后都要记审计日志。哪些请求调了哪个工具、传了什么参数、拿到什么结果、耗了多少时间,全量记录。别嫌日志占地方,真出了责任问题,这些是你唯一能自证的东西。
4. 关键环节的实现细节
4.1 Agent 主循环的代码骨架
这块我直接贴一段经过裁剪的核心代码,逻辑上足够你复刻一个最小内核。它做的事很简单:接收任务、循环让模型决策、调用工具、再把观察结果喂回去。
import asyncio import json from typing import Any class LocalAgent: def __init__(self, llm_client, tools, max_iterations=5): self.llm_client = llm_client self.tools = tools self.max_iterations = max_iterations self.tool_map = {t.name: t for t in tools} async def run(self, user_query: str) -> dict: messages = [{"role": "user", "content": user_query}] for step in range(self.max_iterations): response = await self.llm_client.chat(messages) action = self._parse_action(response["content"]) if action["type"] == "final": return {"answer": action["answer"], "steps": step + 1} if action["type"] == "tool": tool_result = await self._execute_tool(action["tool_call"]) messages.append({ "role": "user", "content": f"工具调用结果:\n{tool_result[:2000]}" }) return {"answer": "达到最大迭代次数,未完成任务", "steps": self.max_iterations} async def _execute_tool(self, tool_call: dict) -> Any: tool_name = tool_call["name"] tool_args = tool_call["arguments"] if tool_name not in self.tool_map: return "错误:未找到该工具" return await self.tool_map[tool_name].execute(**tool_args) def _parse_action(self, text: str) -> dict: # 自定义解析逻辑,兼容 JSON 输出或 markdown 代码块 try: return json.loads(text) except Exception: return {"type": "tool", "tool_call": self._manual_parse(text)}核心要点是_parse_action。这个函数决定了大模型的输出能不能稳定变成工具调用。我踩过很多次模型输出不按 JSON 格式来的坑,所以我的_parse_action里做了很强壮的兼容:先从 markdown 代码块里提 JSON,提不出来就从文本里正则抽取工具名(参数),再不行就模糊匹配。你可以在运维后台里监控一下,看看失败的是哪一种,再来调提示词。
这个循环看着简单,真正难的是你怎么把复杂的业务约束写进去。比如某个工具调用之后返回的字段太多,导致上下文爆炸;又比如上一次调用的结果明显不对,模型还在那里硬编。这些都需要在循环内部加防御逻辑,不能指望大模型每次都对。
4.2 模型上下文管理与截断策略
隔离内网里你很难买到超大上下文的服务,更多时候是 8K 或者 16K 的模型。而 Agent 一轮轮对话下来,历史会越滚越长,尤其工具返回的内容经常是几百行 JSON。如果不做管理,很快就把上下文塞满。我这里的策略很简单:
- 系统提示词和工具描述是固定的,算作固定开销,尽量优化精简,废话不多说。
- 历史消息保留最近 N 轮,早期的对话可以压缩成摘要,这个摘要可以由 Agent 自己生成,也可以是一个独立的小模型做。
- 工具调用的原始返回不做全文保留,只保留截断后的关键字段和状态码。
- 如果用户输入特别长,先做一次“问题压缩”,把冗余信息去掉再喂给模型。
每一条我都在生产里遇到过问题。特别是截断工具返回,如果不告诉模型“结果已经被截断”,模型会一本正经地基于不完整数据得出结论。所以我通常会在截断位置加上一句标识:[结果过长,仅展示前 500 字],并明确告知模型不能臆测未展示部分的内容。
4.3 内网环境下的并发与性能优化
内网 Agent 和公网服务一个很大的差异是,你没法靠弹性扩容来扛住突发流量。你手上的 GPU 就那一两块,服务一旦沦为串行,体验会非常差。我在这边做了几层优化:
第一层是请求级别的并发控制。同一个 Agent 实例的多个任务,不要无脑调度模型服务。模型推理是一个高 CPU/GPU 消耗操作,我把任务压进一个队列,并发数限制在 1~2,保证每次推理都拿到完整计算资源。
第二层是结果缓存。很多业务查询在短时间内是重复的,比如不同的人问同一个“本周订单量”,如果模型生成的 SQL 相同,我可以直接把上一次的结果返回,连模型推理都省了。但注意这里要做时间窗口失效,比如 5 分钟有效,避免脏数据。
第三层是 Embedding 的预加载。很多文档内容不会频繁变化,提前把切块向量算好存入向量库,用户在提问时不需要实时计算全部文档的向量,只做 query 的向量化,响应速度会快很多。
那么性能指标怎么定?我一般会把 P95 响应时间控制在 8 秒以内。如果超过 8 秒,用户就明显觉得“卡”。所以我会把模型服务、向量检索、工具调用的耗时都拆开监控,看是卡在推理上,还是卡在内部接口上,然后再针对性优化。
5. 实操中踩过的坑与排查建议
5.1 依赖问题:最难受的压测阶段遇到的缺库
压测阶段遇到一个奇怪的问题,Agent 在跑一个文档解析脚本时突然崩溃,日志报ModuleNotFoundError: No module named 'unstructured'。我明明在 requirements 里写了这个依赖,搬运包时也下载了,但 module 就是找不到。后来对比了一下发现,原来内网机器上存在两个 Python 环境,pip 默认装到了系统 Python,而启动服务的虚拟环境里根本没装上。这个问题的排查思路其实很简单:确认当前服务运行的 Python 路径和pip install的目标环境是同一个。在踩过几次坑之后,我现在所有的部署脚本开头都强制写清楚 Python 解释器路径,并通过python -m pip install而不是直接pip install,这样能避免很多环境错位的问题。
5.2 模型加载慢与显存不足
小模型还好,7B 模型在 GPU 上加载一般就几十秒。但如果你用的是老一点的显卡,显存只有 11G,那事情就多了。我一开始直接把模型加载到 GPU,跑到第 200 个请求时,显存突然溢出。后面排查发现是新版本 llama.cpp 加载权重时把部分层放到了共享内存里,崩溃和响应变慢交替出现。
我的解决方式很简单:把层数控制住,--n-gpu-layers设成一个保守值,剩下的层跑 CPU。这样确实牺牲了一点首 token 速度,但换来的是长时间运行的稳定性。另外,如果业务并不需要非常强的推理能力,可以换 4bit 或 5bit 量化版模型,几乎不影响可用性,但显存占用能降一个档。
5.3 Agent 陷入死循环
内网 Agent 看起来最麻烦的问题是“模型自己绕不出来”。比如让它分析某个报表,它一直调用“获取报表列表”,拿到结果之后又开始调“获取报表详情”,反复调用同一个工具,形成死循环。我加了两道保险。
第一道是最大迭代次数,也就是 agent 循环最多跑 N 次,超过就主动收尾。这个必须有,否则长时间占着 GPU,其他人全卡死。第二道是“重复动作检测”。如果 Agent 连续三次调用同一个工具且参数完全一样,就不再执行,而是返回一个提醒:“检测到重复动作,请改变策略或直接回答当前问题”。
这两道保险配合使用,基本上把死循环问题解决到了 95% 以上。你会发现在这个过程中,Agent 偶尔会“偷懒”,比如直接根据已有信息回答而不是继续调工具,这其实是可接受的,我们可以通过提示词引导它,但不要过度强求每次都做完所有工具。
5.4 工具调用 JSON 解析失败
还有一个高频问题,就是模型输出不规范的 JSON。模型有时候会返回:
{ "name": "query_sales_order", "arguments": "{\"customer_name\": \"张三\"}" }arguments 里面套了一层字符串,解析的时候没有json.loads就会报错。或者它返回的是 markdown 代码块的 JSON,直接当文本处理也会报错。我这里专门写了一个load_action_from_text函数,做了三层解析:先看是不是合法 JSON,再看是不是 markdown 代码块,最后用正则抽函数名和参数。经过这种处理,解析成功率可以到 99.7%,剩下的 0.3% 就直接报错误,让用户重新描述。
为了更直观,我把这些常见问题整理成一个速查表。
| 问题场景 | 典型表现 | 排查方向 | 应急措施 |
|---|---|---|---|
| 依赖缺失 | 启动时报 ModuleNotFoundError | 查看 Python 路径是否匹配 | python -m pip install --no-index --find-links=... |
| 显存溢出 | 运行一段时间后服务崩溃 | 观察nvidia-smi显存曲线 | 降低--n-gpu-layers或换小模型 |
| Agent 死循环 | 日志显示重复调用同一工具 | 检查重复动作检测逻辑 | 增加最大迭代次数限制 |
| JSON 解析失败 | 工具调用时频繁抛出异常 | 查看原始模型输出 | 更新解析函数兼容多层格式 |
| 向量检索结果差 | 回答的内容和问题无关 | 检查文档切块粒度 | 缩短切块长度,增加重叠区 |
| 内网接口超时 | 工具调用迟迟无返回 | 检查防火墙和白名单 | 工具执行加超时控制,超时降级 |
5.5 内网接口超时与重试的艺术
工具调用无论是查数据库还是调内部 HTTP API,都会遇到网络或服务超时。内网环境下很多老系统响应非常慢,动不动就几十秒甚至不响应。我一开始在工具函数里只写了简单 try-except,结果 Agent 一直等不到结果,直接进入超时路径,最后回答“数据获取失败”。后来我给每个工具的执行都包了一层asyncio.wait_for,超时时间根据工具类型分别设置。数据库类工具最长给 15 秒,内部 HTTP 工具给 5 秒,文档解析类工具给 30 秒。
超时之后的降级策略也很重要。如果查询超时,我会先尝试换一个参数范围再查,实在不行才告诉用户“暂时无法获取数据,请稍后重试”。Agent 不能老是直接摆烂,要让它学会“换条路走走”。
6. 最后说几句
隔离内网里的 AI Agent 项目,真正卡人的往往不是模型聪明不聪明,而是工程上能不能在“没有外网”的约束下把全套系统转起来。我见过太多项目在选型阶段就陷入“想要最强模型、最多工具”的幻想,结果在内网里折腾三个月连一个最小闭环都跑不出来。所以我的实际体会是:第一版能跑通比什么都重要,一个 7B 模型加三个工具加一个向量库,已经能解决很多实际业务问题;后续再慢慢加模型、加记忆、加管