
1. 项目缘起与整体架构设计1.1 为什么要在隔离内网里折腾 AI Agent先说背景。我所在的研发环境是一套完全物理隔离的内网没有外网出口没有公网镜像源连 pip install 都得走内部私有仓库。这种环境下想跑一个 AI Agent 工程第一反应通常是不可能——大模型 API 调不通依赖装不上工具链拉不下来。但实际做下来隔离内网反而逼着我把整个 Agent 架构想得更清楚因为每一个外部依赖都必须找到内网替代方案每一个环节都不能先跑起来再说。这个项目的目标很明确在内网环境里搭建一套可用的 AI Agent 工程让它能完成代码辅助、数据库查询、前端页面生成这几类日常任务。核心组件包括 Agent 调度层、MCP 工具协议层、Skills 技能层、SQLite 本地数据层以及 Vue 前端展示层。整套东西不依赖任何外部服务全部在内网自闭环。适合谁来参考如果你也在受限网络环境里做 AI 工程或者你想理解 Agent 的底层拼装逻辑而不是只会调 API这篇内容应该对你有用。我会把每个选型背后的为什么讲清楚把踩过的坑摊开说让你能直接抄作业。1.2 整体架构的分层思路整套架构我分成四层从下往上依次是数据层SQLite 作为本地存储存 Agent 的会话记录、工具调用日志、Skills 元数据。选 SQLite 的理由很简单——零配置、单文件、内网环境不需要额外起数据库服务一个 .db 文件拷来拷去就能迁移。协议层MCPModel Context Protocol作为 Agent 与工具之间的通信协议。MCP 本质上是一套标准化的工具描述调用接口规范让 Agent 知道有哪些工具可用、每个工具需要什么参数、返回什么结果。能力层Skills 技能模块把具体任务封装成可复用的能力单元比如查数据库、生成 Vue 组件、分析代码结构。展示层Vue 做前端界面展示 Agent 的对话流、工具调用过程、执行结果。这个分层不是拍脑袋定的。最早我试过把所有逻辑塞在一个 Python 脚本里结果改一个工具要动全身。后来拆成四层之后每层职责清晰MCP 协议层做标准化Skills 层做业务封装换模型、换工具、换前端都不影响其他层。1.3 关键选型背后的取舍逻辑为什么用 MCP 而不是自己写工具调用自己写当然可以就是定义个 JSON schemaAgent 输出工具名和参数后端解析执行。但问题是每加一个工具就要改 Agent 的 prompt、改解析逻辑、改错误处理。MCP 把这些标准化了工具注册、参数校验、结果返回都有统一格式Agent 端只需要理解 MCP 协议就行。内网环境里 MCP Server 可以本地起走 stdio 或本地 HTTP完全不依赖外网。为什么 Skills 要单独抽一层MCP 解决的是怎么调工具Skills 解决的是调哪些工具、按什么顺序调、中间结果怎么处理。比如生成一个 Vue 列表页这个 Skill内部可能要调 MCP 的文件写入工具、SQLite 查询工具、模板渲染工具。Skills 层把这些编排逻辑封装起来Agent 只需要说我要生成列表页不用关心底层调了几个工具。SQLite 的角色定位。很多人觉得 SQLite 就是个玩具数据库但在 Agent 工程里它非常合适。Agent 的会话状态、工具调用历史、Skills 注册信息这些数据量不大、读写频繁、需要事务保证SQLite 全都能满足。而且单文件特性让内网迁移变得极其简单直接把 .db 文件复制过去就行。Vue 前端为什么不用 React纯粹是团队技术栈的原因。Vue 的模板语法对后端同学更友好上手快而且内网环境里 Vue 的依赖可以全部本地化不需要 CDN。Vue 3 的组合式 API 在组织 Agent 交互逻辑时也比较清晰。2. 核心组件拆解与实操要点2.1 MCP 协议层的内网落地MCP 的核心概念就三个Tools可调用的工具、Resources可读取的资源、Prompts预定义的提示模板。在内网环境里我主要用 Tools 这一块。一个典型的 MCP Tool 定义长这样# mcp_server.py from mcp.server import Server from mcp.types import Tool, TextContent server Server(internal-tools) server.list_tools() async def list_tools(): return [ Tool( namequery_sqlite, description执行 SQLite 查询返回结果集, inputSchema{ type: object, properties: { db_path: {type: string, description: 数据库文件路径}, sql: {type: string, description: SQL 查询语句} }, required: [db_path, sql] } ), Tool( namewrite_file, description写入文件到指定路径, inputSchema{ type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } ) ] server.call_tool() async def call_tool(name: str, arguments: dict): if name query_sqlite: import sqlite3 conn sqlite3.connect(arguments[db_path]) cursor conn.execute(arguments[sql]) rows cursor.fetchall() conn.close() return [TextContent(typetext, textstr(rows))] elif name write_file: with open(arguments[path], w, encodingutf-8) as f: f.write(arguments[content]) return [TextContent(typetext, text写入成功)]这里有几个实操要点。第一inputSchema一定要写清楚Agent 靠这个理解工具怎么用描述模糊会导致 Agent 传错参数。第二错误处理要完善工具执行失败要返回明确的错误信息而不是抛异常让 Agent 猜。第三内网环境里 MCP Server 用 stdio 模式启动最稳不需要开端口进程间通信。注意MCP Server 的启动命令要写进 Agent 的配置文件里确保 Agent 启动时能自动拉起 MCP Server。内网环境里建议用绝对路径避免工作目录变化导致找不到文件。2.2 Skills 技能层的设计模式Skills 层我采用注册中心执行器的模式。每个 Skill 是一个独立的 Python 类实现统一的接口# skills/base.py class BaseSkill: name: str description: str async def execute(self, params: dict, context: dict) - dict: raise NotImplementedError # skills/vue_generator.py class VueGeneratorSkill(BaseSkill): name vue_generator description 根据描述生成 Vue 组件代码 async def execute(self, params: dict, context: dict) - dict: component_name params.get(name, MyComponent) fields params.get(fields, []) template ftemplate div class{component_name.lower()} table thead tr {.join(fth{f}/th for f in fields)} /tr /thead tbody tr v-foritem in items :keyitem.id {.join(ftd{{{{ item.{f} }}}}/td for f in fields)} /tr /tbody /table /div /template script setup import {{ ref, onMounted }} from vue const items ref([]) onMounted(async () {{ const res await fetch(/api/list) items.value await res.json() }}) /script return {code: template, filename: f{component_name}.vue}Skills 注册中心负责管理所有 Skill 的注册、查找和调用# skills/registry.py class SkillRegistry: def __init__(self): self._skills {} def register(self, skill: BaseSkill): self._skills[skill.name] skill def get(self, name: str) - BaseSkill: return self._skills.get(name) def list_all(self) - list: return [{name: s.name, description: s.description} for s in self._skills.values()]这套设计的好处是新增 Skill 只需要写一个类然后注册进去Agent 端通过 MCP 的list_skills工具就能发现新能力。内网环境里 Skills 的代码全部本地维护不依赖任何外部市场。2.3 SQLite 数据层的表结构设计SQLite 在这个项目里承担三个职责存会话历史、存工具调用日志、存 Skills 元数据。表结构设计如下-- 会话表 CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, title TEXT, status TEXT DEFAULT active ); -- 消息表 CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, -- user / assistant / tool content TEXT, tool_calls TEXT, -- JSON 格式存储工具调用信息 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ); -- 工具调用日志表 CREATE TABLE tool_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, tool_name TEXT NOT NULL, arguments TEXT, -- JSON result TEXT, duration_ms INTEGER, status TEXT, -- success / error created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Skills 注册表 CREATE TABLE skills ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, description TEXT, enabled INTEGER DEFAULT 1, config TEXT, -- JSON 配置 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里有个细节值得说tool_calls和arguments字段用 TEXT 存 JSON而不是拆成多张关联表。原因是 Agent 的工具调用参数结构不固定每个工具的参数 schema 都不一样用 JSON 存最灵活。查询的时候用 SQLite 的json_extract函数就能解析。提示SQLite 默认不开 WAL 模式并发写入会锁库。Agent 场景下工具调用日志写入频繁建议启动时执行PRAGMA journal_modeWAL;开启写前日志提升并发性能。2.4 Vue 前端的交互设计前端这块我用的 Vue 3 Vite核心页面就一个Agent 对话界面。左边是对话流右边是工具调用面板底部是输入框。关键交互逻辑是流式展示 Agent 的思考过程和工具调用结果。这里用 SSEServer-Sent Events从后端推流到前端// composables/useAgentStream.js import { ref } from vue export function useAgentStream() { const messages ref([]) const isStreaming ref(false) async function sendMessage(text) { messages.value.push({ role: user, content: text }) isStreaming.value true const response await fetch(/api/agent/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: text }) }) 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) const lines chunk.split(\n).filter(l l.startsWith(data: )) for (const line of lines) { const data JSON.parse(line.slice(6)) if (data.type token) { // 追加到当前 assistant 消息 const last messages.value[messages.value.length - 1] if (last last.role assistant) { last.content data.content } else { messages.value.push({ role: assistant, content: data.content }) } } else if (data.type tool_call) { messages.value.push({ role: tool, content: 调用工具: ${data.name}, toolData: data }) } } } isStreaming.value false } return { messages, isStreaming, sendMessage } }Vue 这边有个坑要注意流式更新时如果直接改数组元素的contentVue 的响应式系统能检测到但频繁更新会导致大量重渲染。我的做法是用shallowRef包一层或者把流式内容单独放一个 ref最后再合并到消息列表。3. 完整实操流程与关键环节实现3.1 内网环境的基础准备内网环境第一步是把所有依赖本地化。Python 依赖用pip download在有网机器上下载 whl 包然后拷贝到内网用pip install --no-index --find-links./packages安装。Node 依赖同理用npm pack或者直接拷贝node_modules。SQLite 不需要额外安装Python 标准库自带sqlite3模块。但如果要用命令行工具Linux 下可以装sqlite3包或者用 DB Browser for SQLite 的便携版拷贝过去就能用。Vue 的依赖本地化稍微麻烦一点。我的做法是在有网环境npm install之后把整个node_modules和package-lock.json一起拷贝到内网。Vite 的构建也全部本地执行不依赖任何 CDN。注意内网环境里 npm 的 registry 要指向内部私有仓库或者直接用--offline模式。Vite 的optimizeDeps配置里要加上include明确列出需要预构建的依赖避免运行时动态发现导致失败。3.2 Agent 调度核心的实现Agent 调度核心是整个工程的大脑负责接收用户输入、决定调用哪些工具、组织最终回复。我用的是ReAct模式Reasoning ActingAgent 先思考需要什么信息然后调用工具获取再根据结果继续思考直到能给出最终答案。# agent/core.py import json from skills.registry import SkillRegistry class AgentCore: def __init__(self, llm_client, mcp_client, skill_registry): self.llm llm_client self.mcp mcp_client self.skills skill_registry self.max_iterations 10 async def run(self, user_input: str, session_id: str): messages [ {role: system, content: self._build_system_prompt()}, {role: user, content: user_input} ] for i in range(self.max_iterations): response await self.llm.chat(messages) # 检查是否有工具调用 if response.tool_calls: for tool_call in response.tool_calls: result await self._execute_tool(tool_call) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) continue # 没有工具调用返回最终结果 return response.content return 达到最大迭代次数任务未完成 async def _execute_tool(self, tool_call): name tool_call.function.name args json.loads(tool_call.function.arguments) # 先查 Skills skill self.skills.get(name) if skill: return await skill.execute(args, {}) # 再查 MCP 工具 return await self.mcp.call_tool(name, args) def _build_system_prompt(self): tools_desc self.mcp.list_tools() skills_desc self.skills.list_all() return f你是一个内网环境下的 AI 助手可以使用以下工具和技能 可用工具 {json.dumps(tools_desc, ensure_asciiFalse, indent2)} 可用技能 {json.dumps(skills_desc, ensure_asciiFalse, indent2)} 请根据用户需求合理调用工具和技能来完成任务。 每次调用工具后根据返回结果决定下一步行动。 这里的关键点是 system prompt 的构建。Agent 需要知道有哪些工具可用每个工具的参数格式是什么。MCP 的list_tools返回的 schema 直接塞进 prompt 里Agent 就能理解。3.3 工具调用的完整链路从用户输入到工具执行再到结果返回完整链路是这样的用户在前端输入帮我查一下用户表有多少条记录前端通过 SSE 发送到后端/api/agent/chatAgentCore 收到输入构建 messages调用 LLMLLM 返回 tool_callquery_sqlite(db_pathdata/app.db, sqlSELECT COUNT(*) FROM users)AgentCore 解析 tool_call调用 MCP Client 的call_toolMCP Client 通过 stdio 发送请求到 MCP ServerMCP Server 执行 SQLite 查询返回结果结果回传给 LLMLLM 生成自然语言回复回复通过 SSE 流式推送到前端这个链路里最容易出问题的是第 6 步stdio 通信的编码问题。内网环境里如果系统默认编码不是 UTF-8中文参数会乱码。解决方案是在 MCP Server 启动时显式设置PYTHONIOENCODINGutf-8。3.4 SQLite 的读写优化实践Agent 场景下 SQLite 的读写有几个特点写入频繁每条消息、每次工具调用都要写日志、读取随机查询历史会话、数据量不大但增长快。针对这些特点我做了几个优化开启 WAL 模式。默认的 rollback journal 模式下写操作会阻塞读操作。WAL 模式下读写可以并发对 Agent 这种读写混合的场景提升明显。conn sqlite3.connect(db_path) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL) conn.execute(PRAGMA cache_size-64000) # 64MB 缓存批量写入。工具调用日志不要每次单独 INSERT攒一批用executemany批量写入。我设的阈值是 50 条或者 5 秒哪个先到就触发写入。索引优化。messages表的session_id和tool_logs表的session_id都建了索引查询历史会话时不用全表扫描。CREATE INDEX idx_messages_session ON messages(session_id); CREATE INDEX idx_tool_logs_session ON tool_logs(session_id); CREATE INDEX idx_tool_logs_created ON tool_logs(created_at);提示SQLite 的db_path在内网环境里建议用绝对路径避免工作目录变化导致找不到数据库文件。另外定期用VACUUM命令整理数据库文件删除大量数据后文件不会自动缩小。3.5 Vue 前端的构建与部署Vue 项目在内网构建的流程# 1. 安装依赖离线模式 npm install --offline # 2. 开发模式启动 npm run dev # 3. 生产构建 npm run build # 4. 构建产物在 dist/ 目录可以直接用 Nginx 托管如果要把 Vue 打包进 Spring Boot 项目把dist/目录下的文件拷贝到src/main/resources/static/下Spring Boot 会自动作为静态资源提供。Vue Router 用 history 模式的话需要配置一个 fallback 到index.html的控制器。Controller public class SpaController { RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; } }内网环境里 Vue 的构建产物不需要 CDN所有 JS/CSS 都打包在 dist 里。如果用了 Mapbox 这类需要外部资源的库要提前把相关资源本地化或者换成内网可用的替代方案。4. 常见问题与排查技巧实录4.1 MCP 工具调用失败的排查思路MCP 工具调用失败是最常见的问题表现是 Agent 说我要调用某个工具但实际没执行或者执行了但返回错误。排查顺序如下现象可能原因排查方法Agent 不调用工具工具描述不清晰检查list_tools返回的 description 是否准确调用参数错误inputSchema 定义有误用 MCP Inspector 手动测试工具工具执行超时工具内部阻塞加日志看卡在哪一步返回结果乱码编码问题检查 PYTHONIOENCODING 环境变量工具找不到注册失败检查 MCP Server 启动日志我遇到最多的是第一种Agent 不调用工具。原因通常是工具描述写得太抽象比如查询数据库这种描述Agent 不知道具体能查什么。改成执行 SQLite 查询语句支持 SELECT/INSERT/UPDATE/DELETE返回结果集之后调用率明显提升。4.2 SQLite 并发写入的锁问题Agent 高频调用工具时SQLite 会出现database is locked错误。这是因为默认模式下写操作会获取排他锁其他写操作要等待。解决方案开启 WAL 模式前面说过设置busy_timeout让写操作等待而不是立即报错用连接池管理连接避免频繁创建销毁conn sqlite3.connect(db_path, timeout10) # 10秒超时 conn.execute(PRAGMA busy_timeout10000)如果还是频繁锁库考虑把日志写入改成异步队列单独一个线程负责写 SQLite其他线程只往队列里放数据。4.3 Vue 流式渲染的性能问题Agent 流式输出时如果每条 token 都触发 Vue 重渲染页面会卡。我试过几种方案直接改messages数组元素的content能工作但频繁更新导致大量重渲染用shallowRef包一层减少响应式追踪但需要手动触发更新用requestAnimationFrame节流把 token 攒到一帧再更新最终我用的方案是节流 局部更新。流式内容单独放一个streamingContentref每 100ms 合并一次到消息列表。这样既保证了流畅度又不会丢内容。4.4 内网依赖安装的坑内网装 Python 包最常见的坑是依赖树不完整。pip download只下载指定包不会自动下载它的依赖。正确做法是用pip download -r requirements.txt -d ./packages把所有依赖都下载下来。另一个坑是平台兼容性。有网机器是 Windows内网是 Linux下载的 whl 包不兼容。解决方案是在pip download时指定平台--platform manylinux2014_x86_64 --only-binary:all:。Node 这边npm install之后node_modules里可能有平台相关的二进制文件比如 esbuild跨平台拷贝会出问题。建议在内网机器上重新npm install --offline用本地缓存安装。4.5 Agent 陷入循环的终止策略Agent 有时候会陷入调用工具-思考-再调用同一个工具的循环。我在 AgentCore 里加了几层保护max_iterations限制最大迭代次数默认 10 次相同工具相同参数连续调用超过 3 次强制终止单次会话总 token 消耗超过阈值强制终止def _check_loop(self, tool_name, args, history): key f{tool_name}:{json.dumps(args, sort_keysTrue)} count sum(1 for h in history[-6:] if h.get(key) key) return count 3这个检查放在_execute_tool之前如果检测到循环就直接返回错误信息让 Agent 换策略。5. 工程化扩展与个人经验5.1 从单机到多用户的扩展思路当前这套架构是单机单用户的如果要扩展到多用户需要改几个地方。SQLite 换成 PostgreSQL 或者 MySQL因为多用户并发写入 SQLite 扛不住。MCP Server 改成 HTTP 模式每个用户会话独立管理。Vue 前端加登录和会话隔离。但说实话内网环境里多用户场景不多大部分时候就是几个人用。我的建议是先用 SQLite 跑起来真到了性能瓶颈再换。过早优化是万恶之源。5.2 Skills 的复用与组合Skills 最大的价值是可组合。比如生成 CRUD 页面这个 Skill内部可以调用生成 Vue 组件、生成后端接口、生成 SQLite 建表语句三个子 Skill。组合的方式是在 Skill 的execute方法里调用其他 Skillclass CrudPageSkill(BaseSkill): name crud_page async def execute(self, params, context): table params[table] fields params[fields] # 调用子 Skill vue_skill context[registry].get(vue_generator) sql_skill context[registry].get(sql_generator) vue_code await vue_skill.execute({name: f{table}Page, fields: fields}, context) sql_code await sql_skill.execute({table: table, fields: fields}, context) return {vue: vue_code, sql: sql_code}这种组合模式让 Skills 层变得非常灵活新需求往往只需要组合已有 Skill 就能满足。5.3 我踩过的几个印象深刻的坑第一个坑是 MCP Server 的进程管理。最开始我没做进程守护MCP Server 挂了 Agent 就完全不能用。后来加了一个健康检查每 30 秒 ping 一次 MCP Server挂了就自动重启。第二个坑是 SQLite 的db_path用了相对路径结果 Agent 在不同工作目录下启动时找不到数据库。改成绝对路径之后问题解决。这个坑很隐蔽因为开发时工作目录固定部署时才暴露。第三个坑是 Vue 的 SSE 连接在页面切换时没有正确关闭导致连接泄漏。后来在onUnmounted里加了reader.cancel()才解决。5.4 后续可以扩展的方向这套架构跑通之后可以往几个方向扩展。一是加更多 Skills比如代码审查、单元测试生成、接口文档生成。二是把 MCP 工具扩展到更多内部系统比如 Jira、Confluence、内部 Git 仓库。三是前端加可视化面板展示 Agent 的工具调用统计、token 消耗趋势、Skills 使用频率。内网环境做 AI Agent 工程限制多但也不是做不了。核心思路是把每个外部依赖都找到内网替代方案把每个环节都做成可替换的模块。MCP 做协议标准化Skills 做能力封装SQLite 做本地存储Vue 做交互展示这套组合在内网环境里跑得挺稳。