1. 项目缘起与整体设计思路
1.1 为什么要在隔离内网里折腾 AI Agent
先说清楚这个项目的背景。我所在的研发环境是一套完全物理隔离的内网,没有外网出口,没有公网 DNS,连 pip 和 npm 都得走内部镜像源。这种环境下想跑一个 AI Agent 工程,最大的矛盾点在于:主流 Agent 框架的默认设计假设你随时能访问外部 API、能拉取远程工具描述、能动态加载 Skills,而隔离内网把这些假设全部推翻了。
我最初的目标很朴素:在内网里搭一套能自动处理日常研发事务的 Agent,比如根据需求文档生成接口骨架、自动整理测试用例、把零散的运维脚本归类成可复用的 Skills。听起来不难,但真正动手才发现,从模型推理服务的部署、MCP 工具链的本地化、Skills 的离线加载,到并发请求的排队与限流,每一个环节都得重新设计。
这个项目适合两类人参考:一类是在金融、政企、军工等强隔离环境里做 AI 落地的工程师;另一类是想理解 AI Agent 底层工程链路、不满足于调 API 的开发者。我会把整套方案的选型逻辑、踩过的坑、能直接抄的配置都摊开讲。
1.2 整体架构的分层设计
隔离内网下的 Agent 工程,我把它拆成四层,每层职责边界必须清晰,否则后期维护会非常痛苦。
| 层级 | 职责 | 内网约束下的选型 |
|---|---|---|
| 推理层 | 提供 LLM 推理能力 | 本地部署开源模型,vLLM 或 Ollama |
| 协议层 | Agent 与工具通信 | MCP 协议本地化,stdio 传输优先 |
| 能力层 | 具体 Skills 实现 | 文件系统加载,禁止远程拉取 |
| 编排层 | 任务调度与并发控制 | 自研轻量调度器,信号量限流 |
这个分层不是拍脑袋定的。推理层放最底下,是因为内网里模型服务是最稀缺的资源,必须集中管理;协议层用 MCP 而不是自定义 RPC,是因为 MCP 的标准化能让 Skills 在不同 Agent 之间复用;能力层强制文件系统加载,是为了审计和版本控制;编排层自研而不用现成框架,是因为主流框架的并发模型在内网低配环境下反而成了负担。
提示:分层的关键原则是"上层可以依赖下层,下层绝不感知上层"。我见过太多项目把工具调用逻辑写进推理服务里,结果换模型时整个工程推倒重来。
1.3 核心设计原则:离线优先与最小依赖
整个工程我坚持三条原则,这三条直接决定了后面所有技术选型。
第一条,离线优先。任何需要运行时访问外网的组件一律排除。这意味着不能用那些启动时去拉取工具列表的框架,不能用需要在线校验 License 的中间件。所有依赖必须提前下载好,打成离线包,通过内网的文件摆渡流程导入。
第二条,最小依赖。内网环境装个 Python 包都可能因为缺少系统库而失败,所以依赖越少越好。我最终的核心运行时只依赖 Python 标准库加三个第三方包:一个 HTTP 客户端、一个 JSON Schema 校验库、一个进程管理库。听起来寒酸,但实测下来稳定性远超那些依赖几十个包的方案。
第三条,显式优于隐式。Agent 的每一个行为都要可追溯。工具调用走了哪条路径、Skills 从哪个文件加载、并发请求在哪个队列排队,全部打日志。内网环境出问题没法上网搜,日志就是唯一的救命稻草。
2. 推理层:内网模型服务的部署与调优
2.1 模型选型:不是越大越好
内网部署模型,第一个要回答的问题是选多大的模型。我的建议是先看显存,再看任务复杂度,最后才看参数规模。
我手头的推理服务器是两张 24G 显存的卡。一开始想上 32B 的模型,量化到 4bit 勉强能跑,但并发一上来就 OOM。后来退到 14B 级别,用 8bit 量化,单卡就能稳定服务,吞吐量反而上去了。这里有个反直觉的结论:在内网 Agent 场景下,模型的响应速度和稳定性比绝对能力更重要。Agent 一次任务可能要调用十几次模型,每次慢两秒,整体体验就崩了。
具体选型时我列了个对照表:
| 模型规模 | 量化方式 | 显存占用 | 单请求延迟 | 适用场景 |
|---|---|---|---|---|
| 7B | 4bit | 约 6G | 0.8s | 简单分类、抽取 |
| 14B | 8bit | 约 16G | 1.5s | 代码生成、多步推理 |
| 32B | 4bit | 约 20G | 3.2s | 复杂规划(慎用) |
最终我选了 14B 8bit 作为主力,7B 4bit 作为轻量任务的快速通道。这个组合的好处是,简单任务走小模型,复杂任务走大模型,整体资源利用率最高。
2.2 推理引擎的部署细节
推理引擎我用的是 vLLM,原因是它对并发请求的处理最成熟,PagedAttention 机制能显著降低显存碎片。部署命令大概长这样:
python -m vllm.entrypoints.openai.api_server \ --model /models/qwen-14b-chat \ --served-model-name agent-main \ --quantization gptq \ --max-model-len 8192 \ --gpu-memory-utilization 0.85 \ --max-num-seqs 16 \ --port 8000几个参数值得展开说。--gpu-memory-utilization 0.85是留 15% 显存给 KV Cache 的动态增长,设太高容易 OOM,设太低浪费显存。--max-num-seqs 16是并发序列上限,这个值直接决定了 Agent 能同时处理多少个请求,我实测 16 是两张卡下的甜点值,再高延迟就明显上升。
注意:内网部署时 vLLM 首次启动会尝试下载 tokenizer 配置,如果模型目录里没有完整的 tokenizer 文件,启动会卡住。务必提前把模型目录下的所有文件检查一遍,特别是
tokenizer_config.json和special_tokens_map.json。
2.3 模型服务的健康检查与降级
内网环境没有完善的监控体系,我加了一套轻量健康检查。每 30 秒向模型服务发一个极短的请求,比如让模型输出一个固定 token,超时 5 秒就标记为不健康。连续三次不健康就触发降级:把请求路由到备用的小模型服务上。
这套机制救过我好几次。有一次大模型服务因为显存泄漏慢慢变慢,健康检查提前发现,自动切到小模型,虽然生成质量下降,但至少 Agent 没整体挂掉。等运维重启大模型服务后,健康检查恢复,流量自动切回来。
降级策略的配置我放在一个 YAML 文件里,方便内网运维直接改:
health_check: interval: 30 timeout: 5 failure_threshold: 3 fallback: enabled: true target: agent-lite max_duration: 3003. 协议层:MCP 在内网环境下的本地化改造
3.1 MCP 是什么,为什么内网也要用
MCP 全称 Model Context Protocol,是一套让 Agent 和外部工具通信的标准化协议。它的核心价值在于把工具的描述和调用方式标准化了,Agent 不需要为每个工具写适配代码,只要工具实现了 MCP 接口,就能被自动发现和调用。
内网环境用 MCP 有个天然优势:MCP 支持 stdio 传输,也就是工具作为子进程运行,通过标准输入输出通信。这种方式完全不依赖网络,天然适配隔离环境。相比之下,HTTP 传输的 MCP 工具在内网里反而麻烦,因为要处理端口分配和服务发现问题。
我最终的选择是:所有工具都用 stdio 传输的 MCP Server 实现。每个工具是一个独立的可执行文件,Agent 启动时按配置拉起这些子进程,通过 JSON-RPC 消息通信。
3.2 MCP Server 的离线加载机制
标准 MCP 的工作流程是 Agent 启动时去某个注册中心拉取工具列表。内网里没有注册中心,我改成从本地配置文件加载。配置文件长这样:
{ "mcpServers": { "file-ops": { "command": "/opt/agent/tools/file_ops", "args": ["--root", "/data/workspace"], "env": {"LOG_LEVEL": "info"} }, "code-gen": { "command": "/opt/agent/tools/code_gen", "args": ["--template-dir", "/opt/agent/templates"] } } }Agent 启动时读取这个配置,逐个拉起子进程,然后通过 MCP 的initialize握手获取每个工具的能力描述。整个过程零网络依赖,纯本地进程通信。
这里有个细节要注意:子进程的启动顺序和超时。如果某个工具启动慢,Agent 不能干等。我的做法是并行拉起所有工具,每个给 10 秒启动窗口,超时的标记为不可用但继续启动其他工具。这样即使某个工具坏了,Agent 整体还能用。
3.3 工具描述的本土化与精简
MCP 工具的能力描述会作为上下文喂给模型,描述越长,占用的 token 越多,模型推理越慢。内网模型上下文窗口有限,所以工具描述必须精简。
我定了个规矩:每个工具的描述不超过 200 个 token,参数说明用最简形式。比如文件操作工具的描述:
工具名: file_ops 功能: 读写内网工作区文件 参数: action: read|write|list path: 相对工作区路径 content: 写入内容(write时必填)这种极简描述让模型能快速理解工具用途,同时节省上下文。实测下来,精简描述后单次任务的平均 token 消耗降低了约 30%。
提示:工具描述里不要写实现细节,只写"做什么"和"怎么调"。模型不关心你内部是用 Python 还是 Rust 实现的。
4. 能力层:Skills 的工程化管理
4.1 Skills 的本质与目录结构
Skills 这个词最近很火,但很多人把它和工具混为一谈。我的理解是:工具是原子能力,Skills 是面向场景的能力组合。比如"读取文件"是工具,"根据需求文档生成接口代码"是 Skill,后者可能内部调用了文件读取、代码生成、格式校验三个工具。
内网环境下 Skills 必须文件化管理,我设计的目录结构是这样的:
/opt/agent/skills/ ├── registry.json # Skill 注册表 ├── code_gen/ │ ├── manifest.json # Skill 元信息 │ ├── prompt.md # 提示词模板 │ └── validator.py # 输出校验逻辑 ├── test_case/ │ ├── manifest.json │ └── prompt.md └── ops_script/ ├── manifest.json └── prompt.md每个 Skill 一个目录,manifest.json描述这个 Skill 的名称、触发条件、依赖工具;prompt.md是喂给模型的提示词模板;validator.py是可选的输出校验脚本。
4.2 Skill 的加载与匹配逻辑
Agent 收到用户请求后,怎么决定用哪个 Skill?我的方案是两阶段匹配:先用关键词粗筛,再用模型精排。
粗筛阶段,遍历所有 Skill 的 manifest,看请求里是否包含触发关键词。比如请求里有"生成接口",就命中code_genSkill。粗筛可能命中多个,进入精排。
精排阶段,把候选 Skill 的描述和用户请求一起喂给模型,让模型选最合适的一个。这一步用 7B 小模型就够了,因为只是做选择题。
def match_skill(user_request, skills): candidates = [s for s in skills if any(kw in user_request for kw in s.keywords)] if len(candidates) <= 1: return candidates[0] if candidates else None prompt = build_ranking_prompt(user_request, candidates) return model_rank(prompt, candidates)这套逻辑的好处是快。粗筛是纯字符串匹配,微秒级;精排只在候选多的时候触发,大部分请求粗筛就唯一命中了。
4.3 Skill 的版本管理与灰度
内网环境改 Skill 不能像外网那样随时热更新,因为可能影响正在运行的任务。我的做法是版本目录 + 软链接切换。
每个 Skill 的每次修改都生成一个新版本目录,比如code_gen_v1、code_gen_v2,然后用一个软链接code_gen指向当前生效版本。要更新时,先创建新版本目录,测试通过后把软链接指过去。回滚就是把软链接指回旧版本。
ln -sfn /opt/agent/skills/code_gen_v2 /opt/agent/skills/code_gen这个机制简单但极其可靠。有一次新版本 Skill 的提示词有歧义,导致生成代码格式错误,我一条命令就回滚了,整个过程不到 5 秒。
注意:软链接切换时,正在执行的任务可能还在读旧版本文件。所以切换前要确保没有活跃任务,或者接受短暂的不一致。我的做法是切换前检查活跃任务数,为 0 才执行。
5. 编排层:并发控制与任务调度
5.1 内网 Agent 的并发挑战
"AI Agent 怎么扛并发"是个高频问题。内网环境的并发挑战和外网完全不同:外网可以水平扩容,加机器就行;内网机器固定,只能靠软件层面的调度优化。
我的场景是十几个研发同时用 Agent,高峰期可能有二三十个请求同时进来。模型服务只有两张卡,并发序列上限 16,超出的请求必须排队。如果排队策略不当,要么用户等太久,要么模型服务被压垮。
5.2 基于信号量的限流设计
核心思路是用信号量控制同时进入模型服务的请求数。我设了两个信号量:一个控制总并发(上限 16),一个控制单用户并发(上限 3)。这样既能跑满模型服务,又防止单个用户刷爆队列。
import asyncio total_sem = asyncio.Semaphore(16) user_sems = {} async def handle_request(user_id, request): if user_id not in user_sems: user_sems[user_id] = asyncio.Semaphore(3) async with user_sems[user_id]: async with total_sem: return await call_model(request)这个设计的关键是双层信号量的获取顺序。必须先获取用户级信号量,再获取全局信号量。反过来会导致死锁:一个用户占着全局名额等自己的用户名额,而用户名额被其他等全局名额的请求占着。
5.3 任务队列与优先级
信号量解决了并发上限,但没解决排队顺序。我加了一个优先级队列,规则是:交互式请求优先于批处理请求,短任务优先于长任务。
交互式请求就是用户在界面上等着结果的那种,必须快;批处理请求比如夜间批量生成测试用例,可以慢慢跑。短任务优先是为了降低平均等待时间,这是队列论的经典结论。
优先级用整数表示,数字越小优先级越高:
| 任务类型 | 优先级 | 说明 |
|---|---|---|
| 交互式短任务 | 1 | 用户实时等待 |
| 交互式长任务 | 2 | 用户实时等待但耗时长 |
| 批处理短任务 | 5 | 后台执行 |
| 批处理长任务 | 9 | 后台执行,可中断 |
队列用 Python 的heapq实现,简单可靠。每个任务入队时带上优先级和时间戳,出队时按优先级排序,同优先级按时间戳先到先出。
5.4 超时与熔断
内网环境最怕的是请求卡死。我设了三层超时:单次模型调用 30 秒,单个 Skill 执行 120 秒,整个任务 600 秒。任何一层超时都触发熔断,释放信号量,返回错误给用户。
熔断后不是简单丢弃,而是把任务状态存下来,用户可以稍后重试。重试时如果发现是模型服务的问题,会自动降级到小模型。
async def execute_with_timeout(task, timeout): try: return await asyncio.wait_for(task, timeout=timeout) except asyncio.TimeoutError: save_task_state(task, "timeout") raise TaskTimeoutError(f"任务超时: {timeout}s")这套机制上线后,再没出现过因为单个请求卡死导致整个 Agent 不可用的情况。
6. 常见问题与排查技巧实录
6.1 模型服务相关的典型故障
内网跑模型服务,我遇到最多的三类问题,整理成速查表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 启动即 OOM | 显存不足或量化配置错 | 看启动日志的显存分配 | 降量化精度或换小模型 |
| 请求延迟突增 | KV Cache 碎片或并发过高 | 看 vLLM 的 metrics | 重启服务或降并发上限 |
| 输出乱码 | tokenizer 不匹配 | 检查模型目录文件完整性 | 补齐 tokenizer 文件 |
其中 tokenizer 问题最隐蔽。有一次模型输出全是乱码,排查了两小时才发现是模型目录里混入了旧版本的 tokenizer 文件。内网环境没法重新下载,最后是从另一台机器的备份里拷过来的。
6.2 MCP 工具进程的僵尸问题
stdio 传输的 MCP 工具是子进程,如果 Agent 异常退出,子进程可能变成僵尸进程,占着端口或文件句柄。我加了个守护逻辑:Agent 启动时先扫描并清理上次残留的子进程,运行中定期检查子进程状态,发现异常就重启。
def cleanup_zombies(): for proc in psutil.process_iter(['pid', 'name', 'cmdline']): if 'agent/tools' in ' '.join(proc.info['cmdline'] or []): if proc.info['pid'] not in active_pids: proc.kill()这个清理逻辑放在 Agent 的启动钩子里,每次启动自动执行。实测下来,僵尸进程导致的"工具不可用"问题基本消失了。
6.3 Skills 匹配错误的调试方法
Skills 匹配错误表现为 Agent 用了错误的 Skill 处理请求。排查时我按这个顺序来:
- 看日志里粗筛命中了哪些 Skill
- 如果粗筛就错了,检查关键词配置
- 如果粗筛对了但精排错了,看精排的模型输入输出
- 如果都对了但执行结果不对,检查 Skill 的 prompt 模板
大部分问题出在第一步,关键词配得太宽泛。比如"生成"这个词同时出现在代码生成和测试用例生成两个 Skill 的关键词里,导致粗筛总是命中两个。解决办法是关键词要具体,用"生成接口"而不是"生成"。
提示:Skill 的关键词配置要定期 review,随着 Skill 增多,关键词冲突会越来越严重。我每个月会跑一次关键词冲突检测,把重叠的关键词列出来人工调整。
6.4 并发场景下的资源竞争
并发高的时候,多个任务可能同时读写同一个文件,导致内容错乱。我的解决方案是文件锁 + 工作区隔离。每个任务分配独立的工作区目录,任务内部的文件操作只在自己的工作区里进行。需要共享的文件通过一个带锁的接口访问。
import fcntl def safe_write(path, content): with open(path, 'w') as f: fcntl.flock(f, fcntl.LOCK_EX) f.write(content) fcntl.flock(f, fcntl.LOCK_UN)工作区隔离还有个额外好处:任务失败后可以直接删掉整个工作区,不留垃圾。我设了个定时任务,每天凌晨清理超过 7 天的已完成任务工作区。
7. 一些实操心得与扩展方向
7.1 内网部署的打包与摆渡
内网和外网之间的文件摆渡是个体力活,但有几个技巧能省事。第一,把所有依赖打成一个大压缩包,包括模型文件、Python 包、工具二进制,一次性摆渡,避免多次往返。第二,压缩包内附一个install.sh,在内网侧一键解压和配置,减少人工操作。第三,压缩包做分卷,单卷不超过摆渡介质容量,避免传输中断重来。
我现在的标准包大概 40G,分 4 卷,摆渡一次约 20 分钟。解压和配置脚本跑完约 10 分钟,整个部署流程半小时内搞定。
7.2 日志与可观测性
内网没有 ELK 这类日志系统,我用最朴素的方式:结构化日志 + 本地轮转。每条日志是 JSON 格式,包含时间戳、任务 ID、用户 ID、事件类型、耗时。日志按天轮转,保留 30 天。
排查问题时,用jq过滤日志比肉眼翻快得多:
cat agent.log | jq 'select(.task_id=="abc123")' | jq -s 'sort_by(.timestamp)'这条命令能把一个任务的所有日志按时间排序输出,完整还原任务执行链路。
7.3 后续可以扩展的方向
这套工程目前跑得挺稳,但还有几个方向可以继续打磨。一是多模型路由,根据任务类型自动选模型,代码生成走大模型,文本分类走小模型,进一步优化资源利用。二是Skill 的自动化测试,每个 Skill 配一组测试用例,更新后自动跑一遍,减少人工验证。三是任务的可视化追踪,把任务执行链路画成图,方便排查复杂问题。
这些扩展我还在陆续做,等有成熟经验了再单独写一篇分享。内网 AI Agent 工程这个领域,坑多但乐趣也多,希望这篇总结能帮到同样在隔离环境里折腾的同行。