
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 一次任务可能要调用十几次模型每次慢两秒整体体验就崩了。具体选型时我列了个对照表模型规模量化方式显存占用单请求延迟适用场景7B4bit约 6G0.8s简单分类、抽取14B8bit约 16G1.5s代码生成、多步推理32B4bit约 20G3.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, timeouttimeout) 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_idabc123) | jq -s sort_by(.timestamp)这条命令能把一个任务的所有日志按时间排序输出完整还原任务执行链路。7.3 后续可以扩展的方向这套工程目前跑得挺稳但还有几个方向可以继续打磨。一是多模型路由根据任务类型自动选模型代码生成走大模型文本分类走小模型进一步优化资源利用。二是Skill 的自动化测试每个 Skill 配一组测试用例更新后自动跑一遍减少人工验证。三是任务的可视化追踪把任务执行链路画成图方便排查复杂问题。这些扩展我还在陆续做等有成熟经验了再单独写一篇分享。内网 AI Agent 工程这个领域坑多但乐趣也多希望这篇总结能帮到同样在隔离环境里折腾的同行。