1. 隔离内网下的 AI Agent 工程,到底难在哪
先说说我为什么会碰这个题目。去年下半年开始,团队里陆续有人尝试用 AI Agent 做一些内部工具的自动化,比如自动整理工单、自动生成周报、自动跑数据核对。一开始大家都是在能连外网的开发机上玩,用云端大模型 API,配几个 MCP 工具,跑得挺欢。但真正要落地到业务系统里,问题就来了——我们的生产环境是物理隔离的内网,没有外网出口,不能调用任何云端服务,所有东西必须在内网里自给自足。
这个约束一下子把很多"看起来很美"的方案打回了原形。云端大模型用不了,得换成内网部署的开源模型;在线 MCP 服务连不上,得自己搭本地 MCP Server;依赖包不能随时 pip install,得提前把离线包准备好;甚至连 SQLite 这种轻量数据库,都得考虑它在内网环境下的读写性能和并发问题。再加上前端要用 Vue 做一个可视化的 Agent 操作界面,整个工程就变成了一个"麻雀虽小五脏俱全"的完整项目。
我前后折腾了大概两个月,踩了不少坑,也总结出一套相对稳定的方案。这篇文章就把整个工程实战过程拆开讲,包括架构选型、MCP 工具链搭建、Skills 设计、SQLite 数据层、Vue 前端集成,以及内网环境下特有的那些坑。适合谁看?如果你正在做 AI Agent 开发,尤其是要在受限网络环境里落地,或者你对 MCP、Skills 这些概念还比较模糊,想找一个完整的实战参考,那这篇应该能帮到你。我会尽量把"为什么这么选"讲清楚,而不是只丢一堆配置让你抄。
2. 整体架构设计与选型思路
2.1 为什么是"本地模型 + MCP + Skills"这套组合
隔离内网最大的约束就是没有外部服务可用。这意味着三件事:模型必须本地部署,工具调用必须本地实现,数据必须本地存储。基于这个前提,我把架构分成了四层。
最底层是模型层,跑的是内网部署的开源大模型,通过一个兼容 OpenAI 接口的本地推理服务暴露出来。这样上层代码不用关心底层是什么模型,换模型只需要改一个 base_url。中间是Agent 核心层,负责对话管理、工具调度、上下文维护。再往上是工具层,也就是 MCP Server 和 Skills 的集合,Agent 通过 MCP 协议调用这些工具。最上面是交互层,用 Vue 做的 Web 界面,方便非技术同事也能用。
为什么选 MCP 而不是自己写一套工具调用协议?因为 MCP 已经是事实上的标准了,社区里有大量现成的 Server 实现,比如文件操作、数据库查询、HTTP 请求这些通用能力,直接拿来用就行。而且 MCP 的协议设计比较清晰,工具描述、参数 schema、返回值格式都有规范,Agent 理解起来不容易出错。自己造轮子的话,光是工具描述这块就得反复调,不划算。
Skills 这块要单独说一下。很多人把 Skills 和 MCP 搞混,其实它们解决的是不同层面的问题。MCP 解决的是"Agent 能调用什么工具",Skills 解决的是"Agent 在什么场景下该怎么用这些工具"。举个例子,MCP 提供了一个query_sqlite工具,但 Agent 不知道什么时候该查、查完怎么解读结果,这就是 Skill 要干的事。Skill 本质上是一段结构化的提示词加流程说明,告诉 Agent 在特定任务下的操作步骤和注意事项。
2.2 数据层为什么选 SQLite 而不是别的
内网环境下,数据库选型其实没太多选择。MySQL 和 PostgreSQL 当然更强大,但部署和维护成本高,对于一个 Agent 辅助工具来说有点重。SQLite 的优势很明显:零配置、单文件、不需要独立进程,拷贝一个.db文件就能迁移整个数据库。
但 SQLite 也有它的脾气。最典型的就是并发写入问题。SQLite 默认是串行写入的,同一时刻只能有一个写操作。如果 Agent 频繁写日志或者更新状态,很容易遇到database is locked的错误。我的解决办法是开启 WAL 模式,让读写可以并行,同时把写操作尽量合并成批量提交。
还有一个坑是字段类型修改。SQLite 不支持直接ALTER COLUMN改类型,只能通过"建新表、导数据、删旧表、改名"这套流程来绕。这个后面会详细讲。
2.3 Vue 前端在整个工程里的定位
前端这块,我一开始想的是"能用就行",毕竟核心是 Agent 逻辑。但实际用下来发现,一个好的操作界面能极大提升 Agent 的可用性。比如工具调用的过程可视化、中间结果的展示、历史会话的管理,这些如果没有界面,纯靠命令行,非技术同事根本用不起来。
选 Vue 是因为团队里前端同学熟悉,而且 Vue 的生态比较完整,路由、状态管理、组件库都有成熟方案。内网环境下装依赖是个麻烦事,需要提前把node_modules打包好,或者搭一个内网的 npm 镜像。这个后面实操部分会讲。
3. MCP 工具链在内网环境下的搭建细节
3.1 MCP 到底是什么,用大白话讲清楚
MCP 全称是 Model Context Protocol,翻译过来叫"模型上下文协议"。你可以把它理解成 Agent 和外部工具之间的统一插座标准。以前每个工具都要写一套自己的调用接口,Agent 得分别适配;有了 MCP 之后,所有工具都按同一个协议暴露能力,Agent 只需要会一种调用方式就行。
一个 MCP Server 本质上就是一个进程,它对外声明"我有哪些工具、每个工具需要什么参数、返回什么格式"。Agent 启动时会去连接这些 Server,拉取工具列表,然后在需要的时候调用。整个过程是标准化的,所以社区里的 Server 可以互相复用。
在内网环境下,MCP Server 必须本地运行。我常用的几个是:文件系统 Server(读写本地文件)、SQLite Server(数据库操作)、Shell Server(执行命令)。这些都有开源实现,下载下来直接跑就行,不需要联网。
3.2 内网部署 MCP Server 的实操步骤
假设你已经拿到了 MCP Server 的离线包,部署流程大概是这样的。首先确认运行环境,MCP Server 通常用 Node.js 或 Python 写的,所以内网机器上得有对应的运行时。Node.js 的话建议 18 以上,Python 建议 3.10 以上。
以文件系统 Server 为例,启动命令大概是这样:
node /path/to/mcp-server-filesystem/dist/index.js /allowed/directory这里的/allowed/directory是安全边界,Server 只能访问这个目录下的文件。这个设计很重要,内网环境下虽然相对安全,但也不能让 Agent 随便读写整个文件系统。
启动之后,Agent 那边需要配置连接。配置文件通常是一个 JSON,长这样:
{ "mcpServers": { "filesystem": { "command": "node", "args": ["/path/to/mcp-server-filesystem/dist/index.js", "/data/agent-workspace"] }, "sqlite": { "command": "node", "args": ["/path/to/mcp-server-sqlite/dist/index.js", "/data/agent.db"] } } }配置好之后重启 Agent,它就会自动连接这些 Server 并拉取工具列表。
注意:内网环境下路径一定要用绝对路径,相对路径在不同工作目录下启动会出问题。我在这上面浪费过半天时间,Agent 一直报"工具不可用",最后发现是路径没写对。
3.3 自研 MCP Server 的时机和要点
现成的 Server 覆盖不了所有需求。比如我们内部有个工单系统,需要 Agent 能查询和更新工单状态,这就得自己写一个 MCP Server。自研的时候有几个要点。
第一,工具描述要写清楚。Agent 是靠描述来理解工具用途的,描述模糊它就会乱调。比如"查询工单"这种描述太笼统,应该写成"根据工单 ID 或状态查询工单列表,返回工单编号、标题、当前状态、负责人"。
第二,参数校验要做足。Agent 有时候会传一些奇怪的参数,Server 端必须做好校验,返回明确的错误信息,这样 Agent 才能自我纠正。
第三,返回值要结构化。尽量返回 JSON 格式,字段命名清晰,避免返回一大段自然语言让 Agent 去解析。
自研 Server 的代码结构其实不复杂,核心就是注册工具、定义 schema、实现处理逻辑。用官方 SDK 的话,一个简单的 Server 几十行代码就能搞定。
4. Skills 设计与 Agent 行为控制
4.1 Skills 和 MCP 的分工,别再搞混了
前面提过,MCP 管"能做什么",Skills 管"怎么做"。举个具体例子你就明白了。假设 Agent 要完成"生成本周工单统计报告"这个任务。
MCP 提供的能力是:query_sqlite(查数据库)、write_file(写文件)、read_file(读文件)。但光有这些能力,Agent 不知道该先查什么、怎么聚合、报告格式是什么。这时候 Skill 就派上用场了。
一个 Skill 通常包含:触发条件(什么任务下启用)、操作步骤(先做什么后做什么)、注意事项(容易出错的地方)、输出格式(最终结果长什么样)。它本质上是一段精心设计的提示词,但比普通提示词更结构化、更可复用。
4.2 一个实用 Skill 的完整结构
我拿"工单统计报告"这个 Skill 举例,讲讲它的结构。首先是元信息部分,声明 Skill 名称、适用场景、依赖的工具。
name: weekly-ticket-report description: 生成本周工单统计报告 triggers: - "生成本周工单报告" - "统计本周工单" tools: - query_sqlite - write_file然后是执行步骤部分,用自然语言描述清楚每一步。
## 执行步骤 1. 计算本周的起止日期(周一 00:00 到周日 23:59) 2. 查询工单表,筛选创建时间在本周范围内的记录 3. 按状态分组统计数量:待处理、处理中、已完成、已关闭 4. 按负责人分组统计处理量 5. 计算平均处理时长(已完成工单的关闭时间减去创建时间) 6. 将统计结果写入 /reports/weekly-{日期}.md最后是注意事项和输出模板。
## 注意事项 - 日期计算要用本地时区,不要用 UTC - 处理时长为空的工单(未完成)不计入平均值 - 如果本周没有工单,输出"本周无工单"而不是空报告 ## 输出模板 # 本周工单统计({起止日期}) ## 总体情况 - 新增工单:{数量} - 已完成:{数量} ...这样设计的好处是,Agent 每次执行这个任务时行为一致,不会今天这么干明天那么干。而且 Skill 可以版本化管理,改进了流程就更新 Skill 文件,不用动 Agent 核心代码。
4.3 Skills 的加载和触发机制
Skills 文件放在一个固定目录下,Agent 启动时扫描加载。触发方式有两种:一种是用户显式指定,比如在对话里说"用工单报告 Skill";另一种是 Agent 根据用户意图自动匹配。
自动匹配这块要小心,匹配错了会闹笑话。我的做法是给每个 Skill 定义明确的触发关键词,Agent 先做关键词匹配,匹配到多个再让用户确认。不要一上来就靠语义相似度,内网模型的能力参差不齐,语义匹配经常翻车。
实操心得:Skill 的触发条件宁可写窄一点,也不要写太宽。写宽了会导致 Agent 在不该用的时候乱用,反而降低可靠性。我一开始把"报告"这个词作为触发词,结果用户说"帮我看看这个报告文件"也会触发工单报告 Skill,非常尴尬。
5. SQLite 数据层的性能与运维实战
5.1 十万条数据查询到底要多久
热词里有个"十万条数据 sqlite 查询需要多久",这个问题我被问过很多次。实测下来,在普通机械硬盘上,十万条数据的全表扫描大概在 100-300 毫秒;如果有合适的索引,走索引查询能降到 10 毫秒以内。SSD 上会更快,全表扫描大概 50-100 毫秒。
但这个数字有个前提:表结构合理、查询语句不烂。我见过最离谱的情况是,有人把十万条数据存成一个 JSON 字符串塞进一个字段里,每次查询都要全表读出来再解析,那查询时间直接飙到几秒。所以关键不是数据量,而是表设计和索引。
对于 Agent 场景,我建议把经常查询的字段都建上索引,比如工单 ID、状态、创建时间。但索引也不是越多越好,每个索引都会增加写入开销。一般控制在 3-5 个索引比较合适。
5.2 WAL 模式解决并发写入锁
前面提到的database is locked问题,根源是 SQLite 默认的日志模式(DELETE 模式)在写入时会锁住整个数据库。开启 WAL(Write-Ahead Logging)模式后,读和写可以并行,写入性能也更好。
开启方式很简单,执行一次就行:
PRAGMA journal_mode=WAL;这个设置是持久化的,执行一次之后数据库文件会记住。但要注意,WAL 模式会额外生成-wal和-shm两个文件,备份的时候要一起备份,不然数据会丢。
还有一个参数是busy_timeout,设置成 5000 毫秒,意思是遇到锁的时候等 5 秒再报错,而不是立刻失败。这个对 Agent 这种可能并发调用的场景很有用。
PRAGMA busy_timeout=5000;5.3 修改字段类型的正确姿势
SQLite 不支持直接改字段类型,这是它的设计哲学决定的(动态类型)。但实际开发中总会遇到要改类型的情况,比如一开始把时间存成了字符串,后来想改成整数时间戳。
标准流程是四步:建新表、导数据、删旧表、改名。假设有个tickets表,要把created_at从 TEXT 改成 INTEGER。
-- 1. 建新表 CREATE TABLE tickets_new ( id INTEGER PRIMARY KEY, title TEXT, created_at INTEGER, status TEXT ); -- 2. 导数据(注意类型转换) INSERT INTO tickets_new (id, title, created_at, status) SELECT id, title, strftime('%s', created_at), status FROM tickets; -- 3. 删旧表 DROP TABLE tickets; -- 4. 改名 ALTER TABLE tickets_new RENAME TO tickets;整个过程要放在一个事务里,避免中途出错导致数据不一致。另外操作前一定要备份.db文件,这个不用我多说。
注意:如果表上有索引或触发器,重建后要重新创建。我踩过一次坑,改完字段类型发现查询变慢了,查了半天才想起来索引没重建。
5.4 用 DB Browser for SQLite 做日常维护
内网环境下没有花哨的数据库管理工具,DB Browser for SQLite 是个不错的选择。它是图形化界面,支持浏览数据、执行 SQL、导入导出。离线安装包大概几十兆,拷进内网直接装。
我常用它做几件事:查看表结构和索引、手动执行一些维护 SQL、导出数据做备份。特别是排查问题时,能直观看到数据长什么样,比在代码里打日志方便多了。
6. Vue 前端集成与内网部署
6.1 内网装 Vue 依赖的离线方案
内网装 npm 依赖是个老大难。我的方案是:在外网机器上把node_modules完整打包,连同package.json和package-lock.json一起拷进内网。内网机器上直接解压到项目目录,不要执行npm install,因为内网连不上 registry。
但这样有个问题:如果后续要加新依赖,又得重新打包。所以更好的方案是在内网搭一个 npm 私有镜像,用 verdaccio 之类的工具。不过这个搭建成本高一些,看团队规模决定。
Vue 项目本身的环境配置,vue.config.js里要注意把publicPath设成相对路径,不然部署到子目录下会 404。
module.exports = { publicPath: './', outputDir: 'dist', productionSourceMap: false }6.2 Agent 交互界面的核心组件设计
前端界面我主要做了三个核心组件。第一个是对话区,展示用户和 Agent 的交互历史,支持流式输出。流式输出这块要注意,内网模型的响应速度可能不稳定,要做好缓冲和重连。
第二个是工具调用可视化。Agent 调用 MCP 工具时,界面上要能看到"正在调用 query_sqlite"、"返回 23 条记录"这样的信息。这个对调试和建立信任很重要,用户能看到 Agent 在干什么,而不是干等。
第三个是会话管理。支持新建会话、切换历史会话、删除会话。会话数据存在 SQLite 里,前端通过 API 拉取。
Vue 的路由配置大概是这样:
const routes = [ { path: '/', component: ChatView }, { path: '/sessions', component: SessionListView }, { path: '/tools', component: ToolMonitorView } ]6.3 流式输出的前端处理
Agent 的回复是流式的,前端要能逐字显示。用 fetch 的 ReadableStream 处理比较合适。
const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ message }) }) const reader = response.body.getReader() const decoder = new TextDecoder() while (true) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value) // 追加到消息列表 appendToMessage(chunk) }这里有个细节:内网模型有时候会输出不完整的中文(一个 UTF-8 字符被拆成两个 chunk),所以要用TextDecoder的流式模式,不要每次单独解码。
7. 常见问题与排查技巧实录
7.1 Agent 调用工具失败怎么排查
工具调用失败是最常见的问题,排查思路按这个顺序走。先看 MCP Server 进程还在不在,有时候 Server 崩了 Agent 不知道,还在傻等。然后看 Agent 的日志,确认它到底调用了哪个工具、传了什么参数。最后看 Server 端的日志,确认参数有没有收到、处理逻辑有没有报错。
我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 工具列表为空 | Server 未启动或配置路径错 | 检查进程和配置文件 |
| 调用超时 | Server 处理慢或死锁 | 看 Server 日志,检查数据库锁 |
| 参数错误 | Agent 理解偏差 | 看 Agent 日志里的实际参数 |
| 返回格式异常 | Server 实现问题 | 手动调用工具验证 |
7.2 模型输出不稳定的应对
内网开源模型的能力参差不齐,输出不稳定是常态。我的应对策略有三条。第一,降低单次任务复杂度,把大任务拆成小步骤,每步都让 Agent 确认。第二,用 Skill 约束行为,把流程写死,减少模型的自由发挥空间。第三,加校验和重试,对关键输出做格式校验,不合格就重试。
还有个小技巧:在提示词里明确要求"如果信息不足,先提问再执行",这样能避免 Agent 瞎猜。
7.3 内网环境特有的坑
内网环境下有些坑是外网遇不到的。比如时间同步,内网机器可能没有 NTP 服务,时间会漂移,导致 Agent 生成的报告日期不对。解决办法是定期手动校时,或者在代码里用相对时间而不是绝对时间。
还有磁盘空间,内网机器通常配置不高,Agent 跑久了日志和数据库会占满磁盘。要加日志轮转和数据库清理策略。
最后一个坑是依赖版本,内网装不了新包,所以一开始就要把版本锁死,package-lock.json和requirements.txt都要提交到版本控制,避免不同机器上跑出不同结果。
8. 一些个人体会
这套东西跑通之后,最大的感受是:隔离内网做 AI Agent,难点不在 AI,而在工程。模型能力固然重要,但真正决定能不能落地的是那些琐碎的工程细节——路径配置、并发控制、依赖管理、错误处理。这些在外网环境下可能被各种云服务掩盖了,但在内网里全都暴露出来,必须一个个解决。
另外,Skills 的设计比我想象中重要得多。一开始我觉得这就是提示词,随便写写就行。后来发现,好的 Skill 能让一个能力一般的模型表现得像个专家,而烂的 Skill 能让一个强模型表现得像个傻子。这块值得花时间打磨。
最后分享一个小技巧:在内网部署之前,先在外网环境把整个流程跑通,然后把所有依赖、配置、数据都打包,再整体迁移到内网。这样能避免在内网里反复调试,效率高很多。我一开始是直接在内网搭,结果光是装依赖就折腾了一周,后来改成外网打包再迁移,半天就搞定了。