
1. 项目缘起与整体架构设计1.1 为什么要在隔离内网做 AI Agent先说说这个项目的背景。我所在的团队负责一套工业质检系统的前端与数据层开发办公环境是典型的物理隔离内网——没有外网出口所有依赖包、镜像、模型权重都得靠内部制品库和离线介质搬运。这种环境下想用上 AI Agent 提升开发效率难度比公网环境高一个数量级。但需求是真实存在的。我们每天要处理大量重复性工作SQLite 数据库的字段变更、Vue 组件的批量重构、MCP 工具链的配置同步、Skills 脚本的版本管理。这些活儿如果纯手工做一个迭代周期至少浪费两到三个人天。于是从去年下半年开始我牵头搭了一套完全跑在内网里的 AI Agent 工程体系核心思路是把 Agent 的推理能力、工具调用能力、技能扩展能力全部本地化不依赖任何外部服务。这套体系跑了大半年目前支撑着团队 12 个开发人员的日常开发累计处理了超过 8 万次工具调用SQLite 单表十万条数据的查询响应稳定在 40ms 以内。下面我把整个搭建过程、踩过的坑、以及关键参数选择全部拆开讲。1.2 整体架构分层与选型逻辑整套系统分四层从下往上依次是基础设施层内网 Linux 服务器Ubuntu 22.04 LTSDocker 容器化部署SQLite 作为本地状态存储Agent 核心层基于 Rust 编写的 Agent Runtime负责推理调度、上下文管理、工具路由工具协议层MCPModel Context Protocol作为统一工具接口所有外部能力通过 MCP Server 暴露技能扩展层Skills 机制把高频操作封装成可复用的技能包前端用 Vue 做可视化管理界面为什么核心层选 Rust 而不是 Python这是被逼出来的。内网服务器配置有限Python 的 GIL 在高并发工具调用场景下会成为瓶颈而且 Python 运行时内存占用偏高。Rust 编译出来的二进制文件只有几 MB内存占用稳定在 200MB 以内单机可以同时跑 8 个 Agent 实例。实测下来同样的工具调用负载Rust 版本的 P99 延迟比 Python 版本低 60% 左右。MCP 的选择也是经过对比的。早期我们试过自己定义一套 REST 接口来暴露工具但问题是每个工具都要单独写适配代码维护成本极高。MCP 的价值在于它把工具的描述、参数 schema、调用方式标准化了Agent 只需要理解 MCP 协议就能调用任意工具。这就像 USB 接口统一了外设连接方式一样你不需要为每个设备单独设计接口。Skills 机制则是解决重复造轮子的问题。比如修改 SQLite 字段类型这个操作涉及备份、建新表、迁移数据、删旧表、重建索引五个步骤每次都要重新描述一遍太浪费 token。把它封装成一个 SkillAgent 只需要调用sqlite_alter_column_type这一个技能名就行。2. 核心组件深度拆解与实操要点2.1 Rust Agent Runtime 的关键设计Agent Runtime 是整个系统的心脏它要解决三个核心问题推理调度、上下文窗口管理、工具调用编排。推理调度方面我们用的是本地部署的开源模型通过 llama.cpp 的 Rust binding 调用。这里有个关键参数上下文窗口设为 8192 tokens。为什么不是更大因为内网服务器的显存只有 24GB模型本身占 16GB留给 KV Cache 的空间有限。8192 是在响应质量和并发能力之间找到的平衡点。如果设成 16384单实例并发数会从 8 降到 3得不偿失。上下文管理用的是滑动窗口加摘要压缩的策略。具体做法是保留最近 6 轮完整对话更早的对话用一个小模型做摘要摘要控制在 200 tokens 以内。这样既不会丢失关键信息又能控制上下文长度。实测下来这种策略比简单的截断方式任务完成率高 23%。工具调用编排是 Rust 的强项。我们用 tokio 做异步运行时每个工具调用都是一个独立的 task通过 channel 把结果回传给主循环。这里有个细节工具调用的超时时间设为 30 秒超过就返回超时错误让 Agent 决定是否重试。为什么是 30 秒因为 SQLite 十万条数据的复杂查询最慢也就 2 秒左右MCP 工具的网络往返在内网里不超过 100ms30 秒足够覆盖所有正常场景同时能快速发现死锁问题。// Agent 主循环的核心逻辑简化版 async fn agent_loop(mut self, input: String) - ResultString { self.context.push_user_message(input); loop { let response self.model.infer(self.context).await?; match response.action { Action::ToolCall { name, params } { let result timeout( Duration::from_secs(30), self.tool_router.call(name, params) ).await??; self.context.push_tool_result(result); } Action::FinalAnswer(text) { return Ok(text); } } } }2.2 MCP 工具链的落地配置MCP 在内网环境下的部署有个坑官方 SDK 默认会去外网拉取依赖必须提前把所有 crate 下载好放到内网制品库。我的做法是在有外网的机器上执行cargo vendor把依赖全部打包然后通过离线介质搬进内网。MCP Server 的注册采用配置文件方式放在/etc/mcp/servers.toml[[servers]] name sqlite-tools command /opt/mcp/bin/sqlite-mcp-server args [--db, /data/project.db] env { RUST_LOG info } [[servers]] name vue-tools command /opt/mcp/bin/vue-mcp-server args [--project-root, /workspace/frontend]这里有个经验每个 MCP Server 单独一个进程不要合并。原因是单个 Server 崩溃不会影响其他工具而且可以针对不同 Server 设置不同的资源限制。比如 SQLite Server 给 2GB 内存Vue Server 给 4GB 内存因为要跑 AST 解析。MCP 工具的 schema 定义要尽量精确。我见过很多团队把参数类型写成string就完事了结果 Agent 经常传错格式。正确的做法是用 JSON Schema 把每个参数的类型、范围、默认值都写清楚{ name: sqlite_query, description: 执行 SQLite 查询语句, inputSchema: { type: object, properties: { sql: { type: string, description: SQL 查询语句仅支持 SELECT }, timeout_ms: { type: integer, default: 5000, minimum: 100, maximum: 30000 } }, required: [sql] } }2.3 Skills 机制的设计与封装Skills 本质上是一组预定义的工具调用序列加提示词模板。我们的 Skills 用 YAML 定义放在/opt/agent/skills/目录下Agent 启动时自动加载。以SQLite 修改字段类型这个 Skill 为例name: sqlite_alter_column_type description: 修改 SQLite 表中某个字段的数据类型 parameters: - name: table type: string required: true - name: column type: string required: true - name: new_type type: string required: true enum: [TEXT, INTEGER, REAL, BLOB] steps: - tool: sqlite_query params: sql: PRAGMA table_info({{table}}) save_as: table_info - tool: sqlite_execute params: sql: CREATE TABLE {{table}}_backup AS SELECT * FROM {{table}} - tool: sqlite_execute params: sql: | CREATE TABLE {{table}}_new ( {{#each table_info}} {{name}} {{#if (eq name ../column)}}{{../new_type}}{{else}}{{type}}{{/if}}, {{/each}} ) - tool: sqlite_execute params: sql: INSERT INTO {{table}}_new SELECT * FROM {{table}} - tool: sqlite_execute params: sql: DROP TABLE {{table}} - tool: sqlite_execute params: sql: ALTER TABLE {{table}}_new RENAME TO {{table}}这个 Skill 把原本需要 Agent 推理五六步的操作压缩成一次调用。实测下来token 消耗降低了 70%而且不会因为 Agent 推理偏差导致数据丢失。Skills 的版本管理用 Git 做每个 Skill 文件都有版本号。Agent 加载时会检查版本兼容性避免旧版 Skill 在新版 Runtime 上跑出问题。3. 完整实操流程与关键环节实现3.1 内网环境准备与依赖离线化第一步是把基础环境搭起来。内网服务器装 Ubuntu 22.04最小化安装只保留 SSH 和必要的系统工具。然后配置内部 APT 源把常用的开发包同步进来。Rust 工具链的离线安装是个麻烦事。官方 rustup 需要联网下载我的做法是# 在有外网的机器上 rustup toolchain install 1.75.0 rustup component add rustfmt clippy tar czf rust-toolchain.tar.gz ~/.rustup ~/.cargo # 搬到内网后 tar xzf rust-toolchain.tar.gz -C ~/ export PATH$HOME/.cargo/bin:$PATHCargo 依赖的离线化用cargo vendorcargo vendor --versioned-dirs vendor/ # 生成 .cargo/config.toml mkdir -p .cargo cat .cargo/config.toml EOF [source.crates-io] replace-with vendored-sources [source.vendored-sources] directory vendor EOFSQLite 的安装相对简单但要注意编译选项。我们用的是./configure --enable-fts5 --enable-json1 --enable-rtree make -j$(nproc) sudo make install--enable-fts5开启全文搜索--enable-json1支持 JSON 字段操作--enable-rtree支持空间索引。这三个选项在后续的 Agent 数据查询里都会用到。Vue 前端环境的搭建用 pnpm 做包管理同样需要离线化。pnpm store可以把所有依赖缓存到本地目录然后整体搬到内网。3.2 SQLite 数据层的性能调优Agent 的状态存储、对话历史、工具调用日志全部放在 SQLite 里。十万条数据量下默认配置的查询性能是不够的必须做针对性调优。首先是 PRAGMA 设置PRAGMA journal_mode WAL; PRAGMA synchronous NORMAL; PRAGMA cache_size -64000; PRAGMA temp_store MEMORY; PRAGMA mmap_size 268435456;journal_mode WAL让读写可以并发这对 Agent 场景特别重要因为一边在写日志一边在查历史。cache_size -64000表示分配 64MB 内存做页缓存负数单位是 KB。mmap_size设为 256MB让 SQLite 用内存映射方式读文件减少系统调用开销。索引设计也有讲究。对话历史表按session_id和created_at建联合索引CREATE INDEX idx_history_session_time ON conversation_history(session_id, created_at DESC);工具调用日志表按tool_name和status建索引方便统计各工具的成功率CREATE INDEX idx_tool_log_name_status ON tool_call_log(tool_name, status);实测下来十万条数据的按 session 查询从 380ms 降到 12ms提升超过 30 倍。这里有个细节不要给所有字段都建索引写入性能会急剧下降。我们只给查询频率最高的三个字段建了索引写入吞吐量保持在每秒 5000 条以上。3.3 Vue 管理界面的核心实现前端用 Vue 3 TypeScript Vite 搭建主要功能是查看 Agent 运行状态、管理 Skills、监控工具调用。项目结构src/ ├── components/ │ ├── AgentStatus.vue │ ├── SkillManager.vue │ └── ToolMonitor.vue ├── composables/ │ ├── useAgent.ts │ └── useSkills.ts ├── router/ │ └── index.ts └── stores/ └── agent.tsAgent 状态监控用 WebSocket 实时推送后端每 500ms 推一次状态快照。Vue 这边用shallowRef存状态对象避免深层响应式带来的性能开销import { shallowRef, onMounted, onUnmounted } from vue export function useAgentStatus() { const status shallowRefAgentStatus | null(null) let ws: WebSocket | null null onMounted(() { ws new WebSocket(ws://localhost:8080/agent/status) ws.onmessage (event) { status.value JSON.parse(event.data) } }) onUnmounted(() { ws?.close() }) return { status } }Skills 管理界面用表格展示所有已加载的 Skill支持在线编辑和热重载。这里用到了 Vue 的插槽机制把 Skill 的参数编辑区域做成可复用的组件template SkillCard v-forskill in skills :keyskill.name template #header h3{{ skill.name }}/h3 /template template #params ParamEditor :paramsskill.parameters changeupdateSkill / /template /SkillCard /template工具调用监控用 ECharts 画实时曲线展示各工具的调用频率和延迟分布。数据从 SQLite 的tool_call_log表里聚合查询每 5 秒刷新一次。4. 常见问题排查与避坑经验实录4.1 Agent 工具调用失败的排查思路工具调用失败是最常见的问题表现是 Agent 返回工具执行错误但不说具体原因。我的排查顺序是先看 MCP Server 日志journalctl -u mcp-sqlite -n 100大部分问题在这里能看到再查工具调用日志表SELECT * FROM tool_call_log WHERE status error ORDER BY created_at DESC LIMIT 10最后看 Agent Runtime 日志重点看工具调用的参数序列化有没有问题常见原因和解决方案问题现象根本原因解决方案工具调用超时SQLite 查询没走索引用 EXPLAIN QUERY PLAN 分析补索引参数格式错误JSON Schema 定义不严格补充 type 和 enum 约束MCP Server 崩溃内存泄漏设置 systemd 的 MemoryMax 限制工具返回乱码编码不一致统一用 UTF-8MCP 配置里加 charset有个坑我踩了很久MCP Server 的 stdout 如果被日志占满会导致协议通信阻塞。解决办法是把日志输出重定向到 stderrstdout 只留给 MCP 协议数据。4.2 SQLite 并发写入的锁竞争问题Agent 场景下多个实例同时写 SQLite 是常态WAL 模式虽然支持并发读写但写写之间还是互斥的。我们遇到过写入高峰期大量SQLITE_BUSY错误。解决方案是加一个写入队列所有写操作先入队由单独的写入线程串行执行// 写入队列的核心逻辑 let (tx, mut rx) mpsc::channel::WriteTask(1000); tokio::spawn(async move { let conn Connection::open(project.db).unwrap(); while let Some(task) rx.recv().await { match conn.execute(task.sql, task.params) { Ok(_) task.responder.send(Ok(())).ok(), Err(e) task.responder.send(Err(e)).ok(), }; } });同时设置PRAGMA busy_timeout 5000让 SQLite 在遇到锁时自动重试 5 秒。这两招组合下来SQLITE_BUSY错误从每天几百次降到零。4.3 Skills 热加载的版本冲突Skills 热加载时如果正在执行旧版 Skill会出现版本冲突。我们的做法是给每个 Skill 加一个引用计数执行中的 Skill 不会被卸载等执行完再替换。struct SkillRegistry { skills: HashMapString, ArcSkill, ref_counts: HashMapString, AtomicUsize, } impl SkillRegistry { fn reload(mut self, name: str, new_skill: Skill) { if self.ref_counts.get(name).map_or(0, |c| c.load(Ordering::SeqCst)) 0 { // 有正在执行的调用延迟替换 self.pending_updates.insert(name.to_string(), new_skill); } else { self.skills.insert(name.to_string(), Arc::new(new_skill)); } } }这个机制保证了 Skill 更新不会影响正在执行的任务实测下来零故障。4.4 内网模型推理的性能瓶颈本地模型推理最大的瓶颈是显存。24GB 显存跑 7B 模型上下文 8192 时单实例只能并发 8 路。如果并发请求超过 8 个后面的请求会排队。我们的优化手段有三个请求批处理把多个短请求合并成一个 batch 推理吞吐量提升 3 倍KV Cache 复用相同 system prompt 的请求共享 KV Cache省 30% 显存模型量化用 Q4_K_M 量化模型从 16GB 降到 4.5GB精度损失控制在 2% 以内量化后的模型在代码生成任务上的表现和全精度版本相比通过率只差 1.8 个百分点但显存占用降低了 70%性价比极高。5. 性能数据与扩展方向5.1 实测性能数据汇总跑了大半年积累了一些关键数据分享出来供参考指标数值测试条件Agent 单次推理延迟1.2s7B Q4 模型输入 500 tokens工具调用 P99 延迟85ms内网 MCP 调用SQLite 十万条查询12ms带索引的 session 查询SQLite 写入吞吐5200 条/秒批量插入单机并发 Agent 实例8 个24GB 显存Skills 加载时间45ms120 个 Skill这些数据是在内网服务器双路 Xeon Silver 4310128GB 内存RTX 4090上测的。如果硬件配置不同数据会有出入但量级应该差不多。5.2 后续可以扩展的方向这套体系目前只覆盖了开发辅助场景后续我打算往两个方向扩展。一是多 Agent 协作。现在是一个 Agent 干所有活遇到复杂任务时上下文容易爆。可以拆成规划 Agent和执行 Agent规划 Agent 负责拆解任务执行 Agent 负责具体操作两者通过消息队列通信。二是Skills 市场。团队内部已经积累了 120 多个 Skill可以做一个内部市场让每个人都能贡献和复用 Skill。技术上用 Git 做版本管理Vue 前端做浏览和安装界面后端用 SQLite 存 Skill 的元数据和下载统计。这两个方向我都在做原型验证等跑通了再写一篇详细的实战记录。内网环境做 AI Agent 工程核心就一句话把所有外部依赖都变成内部资产把重复操作都变成可复用技能。做到这两点效率提升是实实在在的。