
1. 项目概述为什么在隔离内网里做 AI Agent 不是“降级”而是“升维”“隔离内网下 AI Agent 工程实战”——这八个字一出来很多同行第一反应是皱眉没有公网、没有云服务、没有大模型 API 直连连 curl 都发不出去还搞什么 AI Agent是不是硬凑热点是不是纸上谈兵我干过三年金融私有云 AI 中台、两年制造业边缘智能平台也亲手在电厂DCS隔离网、银行核心账务区、军工研究所涉密网里部署过几十套本地化智能体系统。我可以很确定地说真正考验工程能力的从来不是联网调 API 的速度而是断网状态下AI Agent 还能不能“呼吸”、能不能“思考”、能不能“动手”。这不是妥协是回归本质——AI Agent 的核心价值从来不是“能调多少个大模型”而是“能否在约束条件下完成闭环任务”。隔离内网恰恰是最严苛的约束场无外网依赖、无中心调度、无实时更新、资源受限、安全审计穿透到每一行日志。它逼你把 Agent 拆解成可验证的原子能力本地推理引擎选型是否真能跑通 RAG 流程SQLite 是否真能承载多轮对话状态工具调用历史知识片段索引Vue SPA 在离线环境下如何实现状态持久化与错误降级MCP ToolsModel Control Protocol这类轻量级协议栈是否能在 2C CPU 4G 内存的老旧工控机上稳定维持心跳这些都不是理论问题是每天要填的工单、要过的等保三级检查项、要扛住的连续72小时无人值守运行压力。所以这篇实战记录不讲“怎么用 LangChain 调通 Qwen3”只讲“当防火墙物理拔掉后你的 Agent 还剩几条命”。关键词里的AI Agent、内网、MCP Tools、Vue SPA、SQLite每一个都不是装饰词而是生存必需品——它们共同构成了一套“断网不瘫痪、离线能干活、审计可追溯、扩容有路径”的最小可行智能体骨架。适合谁看正在做政企私有化部署的算法工程师、负责工业现场智能终端落地的嵌入式开发者、需要给客户交付“真离线AI”的售前架构师以及所有厌倦了“Demo 美如画上线就报错”的实干派。下面我们就从零开始把这套骨架一节一节焊死。2. 整体架构设计放弃“云原生幻想”拥抱“内网原生现实”2.1 为什么必须抛弃标准云架构范式市面上90%的 AI Agent 教程默认你有稳定的公网带宽、随时可扩的 GPU 资源、成熟的 Kubernetes 编排、可观测性全套Prometheus Grafana ELK、以及最重要的——一个能随时调用的商用大模型 API。但在隔离内网里这些全是奢侈品。我见过最典型的翻车场景某市政务AI助手项目前期用 FastAPI LangChain OpenAI API 做出惊艳Demo上线前才发现政务云网络策略禁止所有 outbound HTTPS 请求临时切本地模型发现部署的 Llama3-8B 量化版在4核CPU上推理延迟高达8秒/Token再想换小模型又卡在许可证合规审查上——开源模型的商用条款和政务采购目录对不上。这种“先造火箭再找发射场”的思路在内网里就是自杀。我们必须倒推以硬件底座为起点以安全红线为边界以运维成本为标尺反向设计Agent骨架。我们最终采用的五层洋葱架构每一层都直面内网现实最外层交互层Vue SPA 离线优先不用 SSR不用 CSR 依赖 CDN所有静态资源JS/CSS/字体/图标打包进单页应用通过vue-cli-service build --mode production生成纯静态文件。关键改造vue.config.js中配置publicPath: ./避免绝对路径router/index.js启用history模式但 fallback 到hash模式mode: hash确保 Nginx 或 Caddy 反向代理时无需额外 rewrite 规则所有 API 请求封装进axios实例内置离线检测navigator.onLine 定时 ping 内网网关IP失败时自动切换至本地缓存队列。中间层协调层MCP Tools 协议栈轻量化放弃 LangChain 的复杂抽象层直接基于 MCPModel Control Protocolv1.0 规范实现最小控制平面。MCP 的核心优势在于它不绑定具体模型只定义“工具调用请求/响应”、“状态同步”、“心跳保活”三类 JSON-RPC 2.0 消息格式。我们精简后仅保留 4 个端点/mcp/tools注册工具列表、/mcp/invoke执行工具、/mcp/state同步对话状态、/mcp/health心跳。实测证明一个 Go 编写的 MCP Server使用gorilla/mux在 1C2G 虚拟机上常驻内存仅 12MBQPS 稳定在 180工具调用平均耗时 42ms。能力层执行层SQLite 驱动的本地工具链所有“动作”必须可审计、可回溯、可离线执行。因此我们拒绝 HTTP 调用外部服务所有工具查数据库、读文件、发邮件、调 PLC 接口全部封装为 SQLite 的自定义函数通过sqlite3_create_function注册或预编译语句PRAGMA compile_options启用ENABLE_RTREE支持地理围栏。例如“查询设备故障记录”工具实际执行的是SELECT * FROM fault_log WHERE device_id ? AND timestamp datetime(now, -7 days)“生成周报PDF”工具底层调用wkhtmltopdf命令行并把结果存入report_blob表的 BLOB 字段。这样整个工具链的输入输出、执行时间、操作人来自 JWT token 解析全部沉淀在 SQLite 中审计时直接SELECT * FROM tool_audit_log WHERE tool_name gen_weekly_report ORDER BY exec_time DESC LIMIT 100。模型层推理层量化小模型 本地 RAG放弃 7B 模型选用经过 GGUF 量化Q4_K_M的 Phi-3-mini-4k-instruct1.7GB配合 llama.cpp v0.3.2 在 4C8G 服务器上实测首 Token 延迟 1.2s吞吐 8.3 tokens/s。RAG 知识库不走向量数据库用 SQLite 的 FTS5 全文检索模块构建CREATE VIRTUAL TABLE doc_fts USING fts5(title, content, tokenizetrigram)插入文档时自动分词索引检索时SELECT * FROM doc_fts WHERE doc_fts MATCH 故障代码 E102 ORDER BY rank返回 top-3 片段拼接进 prompt。实测 5000 篇技术手册文档约 2.3GB 文本索引体积仅 860MB查询平均耗时 18ms。存储层基石层SQLite 单文件多角色复用这是最反直觉也最关键的决策整个系统只用一个 SQLite 文件agent.db但通过 WAL 模式 严格事务隔离同时承担五种角色对话状态存储conversations表存 session_id、last_active、system_promptmessages表存 role/content/timestamp/tool_calls工具元数据注册tools表存 name/description/parameters/schematool_audits表存每次调用详情RAG 知识索引doc_fts虚拟表 documents原始表用户权限与凭证users表bcrypt 加密密码、api_keys表JWT secret 存于此避免硬编码系统配置与心跳日志config表存model_path,embedding_dim等health_logs表存/mcp/health返回的 CPU/Mem/Disk 指标。这样做的好处是备份只需cp agent.db agent.db.bak迁移只需拷贝单文件审计时所有证据链在同一事务中可追溯。当然代价是必须精细控制 WAL 日志大小PRAGMA journal_size_limit 67108864即 64MB并每 2 小时执行一次PRAGMA wal_checkpoint(TRUNCATE)防止日志膨胀。提示不要被“SQLite 是玩具数据库”的偏见误导。在内网场景下它的 ACID 保证、零配置、单文件、可嵌入性、成熟度35年演进远超任何所谓“轻量级向量库”。我们线上系统已稳定运行 14 个月最大单库文件 3.2GB未出现一次锁表或损坏。2.2 架构选型背后的硬核权衡为什么不用更“时髦”的方案这里必须说清楚每个取舍的血泪教训不用 Docker/K8s而用 systemd 直接管理进程内网环境往往禁用容器镜像仓库且 K8s 自身就需要 etcd、kube-apiserver 等组件引入新攻击面。我们用systemctl enable --now agent-mcp.service管理 MCP Serversystemctl enable --now agent-web.service管理 Vue 静态服务Caddy所有配置文件放在/etc/agent/下变更后systemctl reload即可生效。实测启动时间比 K8s Pod 快 8.3 倍资源占用低 62%。不用 Redis 缓存而用 SQLite 的内存模式临时表Redis 需要单独维护进程、配置持久化、处理主从同步。我们在 MCP Server 启动时创建内存数据库sqlite3_open(:memory:, db_mem)建临时表CREATE TEMP TABLE cache (key TEXT PRIMARY KEY, value TEXT, expires_at INTEGER)用INSERT OR REPLACE INTO cache ...写入SELECT value FROM cache WHERE key ? AND expires_at strftime(%s, now)查询。内存表生命周期与进程绑定重启即清空天然符合“缓存该有的样子”。不用 WebSockets 做长连接而用 Server-Sent Events (SSE)WebSocket 在内网防火墙/NAT 环境下握手失败率高尤其老版本 FortiGate且需要额外心跳保活逻辑。SSE 基于 HTTP 长连接兼容性极好Vue 前端只需const eventSource new EventSource(/api/sse)后端用fmt.Fprintf(w, data: %s\n\n, jsonBytes)推送。我们实测在 200 并发连接下Go 的net/http服务器内存稳定在 1.2GBCPU 占用 15%。不用 OAuth2而用 JWT SQLite 用户表鉴权OAuth2 需要授权服务器、令牌刷新、PKCE 流程内网用户量通常 1000完全没必要。我们签发 JWT 时payload包含user_id,role,expsecret存在api_keys表中SELECT secret FROM api_keys WHERE key_type jwt_signing每次请求解析 token 后再查users表验证user_id和status active。简单、安全、可审计。3. 核心模块实现手把手拆解 SQLite 如何成为 AI Agent 的“中枢神经”3.1 SQLite 数据库初始化一张表撑起整个世界agent.db的初始化脚本init_db.sql是整个系统的基石必须一次写对。我们摒弃 ORM全程手写 SQL确保每个字段、每个索引、每个约束都精准可控。以下是生产环境使用的完整初始化语句已脱敏保留核心结构-- 启用 WAL 模式支持高并发读写 PRAGMA journal_mode WAL; PRAGMA synchronous NORMAL; PRAGMA mmap_size 268435456; -- 256MB 内存映射 -- 1. 用户与权限表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, -- bcrypt hash role TEXT NOT NULL CHECK(role IN (admin, operator, viewer)), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP, status TEXT DEFAULT active CHECK(status IN (active, disabled)) ); CREATE INDEX idx_users_username ON users(username); -- 2. API 密钥表用于 JWT 签名和前端鉴权 CREATE TABLE api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, key_type TEXT NOT NULL CHECK(key_type IN (jwt_signing, webhook_secret)), secret TEXT NOT NULL, -- 加密存储不内网环境直接明文靠文件权限保护 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP ); -- 插入默认 JWT 签名密钥首次启动时生成随机 32 字节 INSERT INTO api_keys (key_type, secret) VALUES (jwt_signing, a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6); -- 3. 对话会话表 CREATE TABLE conversations ( id TEXT PRIMARY KEY, -- UUID v4 user_id INTEGER NOT NULL, system_prompt TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_conversations_user_id ON conversations(user_id); CREATE INDEX idx_conversations_last_active ON conversations(last_active); -- 4. 消息表支持工具调用链路 CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN (user, assistant, tool)), content TEXT, tool_calls TEXT, -- JSON array of {name, arguments} tool_responses TEXT, -- JSON array of {name, result, error} timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); CREATE INDEX idx_messages_conversation_id ON messages(conversation_id); CREATE INDEX idx_messages_timestamp ON messages(timestamp); -- 5. 工具注册表MCP Tools 元数据 CREATE TABLE tools ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, -- 如 query_device_status description TEXT NOT NULL, parameters TEXT NOT NULL, -- JSON Schema return_schema TEXT, -- JSON Schema for response is_enabled BOOLEAN DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 6. 工具调用审计表所有操作留痕 CREATE TABLE tool_audits ( id INTEGER PRIMARY KEY AUTOINCREMENT, tool_name TEXT NOT NULL, user_id INTEGER NOT NULL, conversation_id TEXT, input_params TEXT, -- JSON output_result TEXT, -- JSON or error string exec_time_ms INTEGER NOT NULL, status TEXT NOT NULL CHECK(status IN (success, error)), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL ); CREATE INDEX idx_tool_audits_tool_name ON tool_audits(tool_name); CREATE INDEX idx_tool_audits_created_at ON tool_audits(created_at); -- 7. RAG 知识库FTS5 全文检索 CREATE VIRTUAL TABLE doc_fts USING fts5( title, content, tokenizetrigram, contentdocuments, content_rowidrowid ); CREATE TABLE documents ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, source_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 创建触发器确保 FTS5 索引与 documents 表同步 CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN INSERT INTO doc_fts(doc_fts, rowid, title, content) VALUES(insert, NEW.id, NEW.title, NEW.content); END; CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN INSERT INTO doc_fts(doc_fts, rowid, title, content) VALUES(delete, OLD.id, OLD.title, OLD.content); INSERT INTO doc_fts(doc_fts, rowid, title, content) VALUES(insert, NEW.id, NEW.title, NEW.content); END; CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN INSERT INTO doc_fts(doc_fts, rowid, title, content) VALUES(delete, OLD.id, OLD.title, OLD.content); END; -- 8. 系统配置表 CREATE TABLE config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, description TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT INTO config (key, value, description) VALUES (model_path, /opt/agent/models/phi-3-mini.Q4_K_M.gguf, LLM 模型文件路径), (embedding_dim, 384, Embedding 向量维度用于未来扩展), (max_context_length, 4096, 模型最大上下文长度), (sse_reconnect_delay_ms, 3000, SSE 断线重连延迟); -- 9. 健康检查日志表 CREATE TABLE health_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, cpu_percent REAL, mem_used_mb INTEGER, disk_used_percent REAL, mcp_uptime_seconds INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_health_logs_created_at ON health_logs(created_at);这个脚本的关键细节新手极易忽略PRAGMA mmap_size 268435456这是性能分水岭。SQLite 默认内存映射很小导致大量磁盘 I/O。设为 256MB 后对doc_fts的全文检索速度提升 3.7 倍实测 1000 次查询平均耗时从 42ms 降至 11ms。计算依据SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()查当前 DB 大小设 mmap 为 DB 大小的 2-3 倍。CREATE VIRTUAL TABLE doc_fts USING fts5(... contentdocuments)content参数是 FTS5 的“内容表”绑定意味着doc_fts不存储原始数据只存索引所有content字段值仍存在documents表中。这避免了数据冗余且INSERT INTO documents时触发器自动同步索引开发者无感。FOREIGN KEY ... ON DELETE CASCADE/SET NULL这是数据一致性的生命线。当删除一个用户时conversations表相关记录自动删除messages表也级联删除tool_audits表中conversation_id设为 NULL保留审计线索。没有这个手动清理会遗漏导致孤儿数据。tool_calls和tool_responses字段存 JSON 字符串不建子表因为工具调用是“一对多”关系但每次消息只关联一次调用即使调用多个工具也打包成一个 JSON 数组。JSON 字符串更灵活避免频繁 ALTER TABLE且 SQLite 的json_extract()函数足够强大如SELECT json_extract(tool_calls, $[0].name) FROM messages。注意SQLite 的json1扩展必须启用。在编译 llama.cpp 或 Go 的 sqlite3 驱动时需添加-DSQLITE_ENABLE_JSON1编译选项。Ubuntu 上安装libsqlite3-dev后默认已启用但 CentOS 7 需要手动编译。3.2 MCP Tools 协议栈实现用 200 行 Go 代码定义智能体“肌肉”MCP 的精髓在于“协议即契约”。我们不实现复杂调度只确保四件事工具可发现、调用可验证、状态可同步、心跳可感知。以下是核心 Go 代码mcp_server.go已精简至生产可用级别package main import ( database/sql encoding/json fmt log net/http time _ github.com/mattn/go-sqlite3 ) type MCPTool struct { Name string json:name Description string json:description Parameters json.RawMessage json:parameters ReturnSchema json.RawMessage json:return_schema,omitempty } type MCPInvokeRequest struct { ToolName string json:tool_name Args json.RawMessage json:args } type MCPInvokeResponse struct { Result interface{} json:result,omitempty Error string json:error,omitempty Status string json:status // success or error } type MCPStateRequest struct { SessionID string json:session_id State json.RawMessage json:state } type MCPHealthResponse struct { UptimeSeconds int64 json:uptime_seconds CPUPercent float64 json:cpu_percent MemUsedMB int json:mem_used_mb DiskUsedPct float64 json:disk_used_percent Timestamp string json:timestamp } var db *sql.DB var startTime time.Time func initDB() { var err error db, err sql.Open(sqlite3, ./agent.db) if err ! nil { log.Fatal(Failed to open database:, err) } // 设置连接池 db.SetMaxOpenConns(20) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(30 * time.Minute) startTime time.Now() } func handleTools(w http.ResponseWriter, r *http.Request) { w.Header().Set(Content-Type, application/json) if r.Method ! http.MethodGet { http.Error(w, Method not allowed, http.StatusMethodNotAllowed) return } rows, err : db.Query(SELECT name, description, parameters, return_schema FROM tools WHERE is_enabled 1) if err ! nil { http.Error(w, DB query failed, http.StatusInternalServerError) return } defer rows.Close() var tools []MCPTool for rows.Next() { var t MCPTool err : rows.Scan(t.Name, t.Description, t.Parameters, t.ReturnSchema) if err ! nil { continue // skip bad rows } tools append(tools, t) } json.NewEncoder(w).Encode(tools) } func handleInvoke(w http.ResponseWriter, r *http.Request) { w.Header().Set(Content-Type, application/json) if r.Method ! http.MethodPost { http.Error(w, Method not allowed, http.StatusMethodNotAllowed) return } var req MCPInvokeRequest if err : json.NewDecoder(r.Body).Decode(req); err ! nil { http.Error(w, Invalid JSON, http.StatusBadRequest) return } // 1. 记录审计日志开始 start : time.Now() tx, err : db.Begin() if err ! nil { http.Error(w, DB transaction failed, http.StatusInternalServerError) return } defer tx.Rollback() // 2. 执行工具此处为伪代码实际调用本地函数 var result interface{} var errStr string switch req.ToolName { case query_device_status: result, errStr queryDeviceStatus(req.Args) case gen_weekly_report: result, errStr genWeeklyReport(req.Args) default: errStr fmt.Sprintf(unknown tool: %s, req.ToolName) } // 3. 写入审计日志 execTime : int(time.Since(start).Milliseconds()) status : success if errStr ! { status error } _, err tx.Exec(INSERT INTO tool_audits (tool_name, user_id, input_params, output_result, exec_time_ms, status) VALUES (?, ?, ?, ?, ?, ?), req.ToolName, 1, string(req.Args), fmt.Sprintf(%v, result), execTime, status) if err ! nil { http.Error(w, Audit log failed, http.StatusInternalServerError) return } if err : tx.Commit(); err ! nil { http.Error(w, Commit failed, http.StatusInternalServerError) return } // 4. 返回响应 resp : MCPInvokeResponse{ Result: result, Error: errStr, Status: status, } json.NewEncoder(w).Encode(resp) } func handleState(w http.ResponseWriter, r *http.Request) { w.Header().Set(Content-Type, application/json) if r.Method ! http.MethodPost { http.Error(w, Method not allowed, http.StatusMethodNotAllowed) return } var req MCPStateRequest if err : json.NewDecoder(r.Body).Decode(req); err ! nil { http.Error(w, Invalid JSON, http.StatusBadRequest) return } // 更新 conversations 表的 last_active _, err : db.Exec(UPDATE conversations SET last_active CURRENT_TIMESTAMP WHERE id ?, req.SessionID) if err ! nil { http.Error(w, Update state failed, http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte({status: ok})) } func handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set(Content-Type, application/json) if r.Method ! http.MethodGet { http.Error(w, Method not allowed, http.StatusMethodNotAllowed) return } // 获取系统指标简化版实际用 gopsutil cpuPct : 12.3 // mock memUsedMB : 1842 diskPct : 42.7 resp : MCPHealthResponse{ UptimeSeconds: int64(time.Since(startTime).Seconds()), CPUPercent: cpuPct, MemUsedMB: memUsedMB, DiskUsedPct: diskPct, Timestamp: time.Now().Format(time.RFC3339), } json.NewEncoder(w).Encode(resp) } func main() { initDB() http.HandleFunc(/mcp/tools, handleTools) http.HandleFunc(/mcp/invoke, handleInvoke) http.HandleFunc(/mcp/state, handleState) http.HandleFunc(/mcp/health, handleHealth) log.Println(MCP Server starting on :8080) log.Fatal(http.ListenAndServe(:8080, nil)) }这段代码的“魔鬼细节”决定了工程成败db.SetMaxOpenConns(20)和db.SetMaxIdleConns(5)SQLite 是文件锁数据库并发连接数不宜过高。20 是压测得出的平衡点低于 15高并发时连接等待高于 25WAL 日志竞争加剧写入延迟飙升。MaxIdleConns5确保常用连接复用避免频繁打开关闭文件句柄。tx, err : db.Begin()显式事务所有工具调用必须原子化。queryDeviceStatus可能读取多个表genWeeklyReport可能写入report_blob表审计日志必须与业务操作同事务提交。否则业务成功但审计失败系统就失去可信度。switch req.ToolName的硬编码路由不用反射或插件机制因为内网工具集固定20个硬编码可读性高、无反射开销、审计时一眼看清所有入口。新增工具只需加 case 分支 更新tools表。execTime : int(time.Since(start).Milliseconds())精确到毫秒的耗时统计是后续容量规划的唯一依据。我们据此绘制“工具耗时热力图”发现gen_weekly_report平均 3200ms于是将其拆分为“数据查询”200ms和“PDF 渲染”异步3000ms两个 MCP 调用前端显示进度条。实操心得MCP Server 启动时务必执行SELECT count(*) FROM tools WHERE is_enabled 1并打印日志。我们曾因运维误操作将is_enabled设为 0导致所有工具“消失”前端报错404 Not Found排查 3 小时才发现是数据库状态而非代码问题。3.3 Vue SPA 离线交互让浏览器成为 AI Agent 的“前线指挥所”Vue 应用不是简单的展示层它是 Agent 的“感官”和“手”。在断网环境下它必须能缓存用户输入、离线渲染历史消息、本地验证表单、甚至模拟工具调用结果。以下是关键实现1. 状态管理Pinia离线持久化stores/conversation.js使用persist插件但配置为storage: window.localStorage非sessionStorage确保页面刷新不丢会话import { defineStore } from pinia import { persist } from pinia-plugin-persist export const useConversationStore defineStore(conversation, { state: () ({ currentSession: null, // { id, user_id, system_prompt } messages: [], // [{ role, content, tool_calls, tool_responses, timestamp }] isLoading: false, error: null }), actions: { async loadSession(sessionId) { this.isLoading true try { // 优先尝试从 localStorage 读取离线可用 const cached localStorage.getItem(conv_${sessionId}) if (cached !navigator.onLine) { const data JSON.parse(cached) this.currentSession data.session this.messages data.messages return } // 在线时从 MCP Server 拉取最新状态 const res await fetch(/mcp/state?session_id${sessionId}) const data await res.json() this.currentSession data.session this.messages data.messages // 同时缓存到 localStorage localStorage.setItem(conv_${sessionId}, JSON.stringify({ session: this.currentSession, messages: this.messages })) } catch (e) { this.error e.message } finally { this.isLoading false } }, async sendMessage(content) { if (!this.currentSession) return const message { role: user, content, timestamp: new Date().toISOString() } this.messages.push(message) // 立即添加 assistant 占位符提升感知速度 this.messages.push({ role: assistant, content: 思考中..., timestamp: new Date().toISOString() }) try { const res await fetch(/mcp/invoke, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ tool_name: llm_inference, // 统一入口 args: JSON.stringify({ messages: this.messages }) }) }) const data await res.json() // 替换占位符 this.messages[this.messages.length - 1] { role: assistant, content: data.result?.response || , tool_calls: data.result?.tool_calls || [], timestamp: new Date().toISOString() } } catch (e) { // 网络失败时显示友好错误并保留占位符 this.messages[this.messages.length - 1].content 网络错误${e.message} } } }, plugins: [persist({ key: conversation-store, storage: window.localStorage, paths: [currentSession, messages] // 只持久化这两个字段 })] })2. SSE 实时流式响应components/ChatBox.vue中用EventSource接收 LLM 的逐 Token 输出替代传统 AJAXscript setup import { ref, onMounted, onUnmounted } from vue import { useConversationStore } from /stores/conversation const props defineProps([sessionId]) const conversation useConversationStore() const eventSource ref(null) onMounted(() { // 只在在线时建立 SSE 连接 if (navigator.onLine) { eventSource.value new EventSource(/api/sse?session_id${props.sessionId}) eventSource.value.onmessage (event) { try { const data JSON.parse(event.data) if (data.type token) { // 追加到最新 assistant 消息 const lastMsg conversation.messages.at(-1) if (lastMsg?.role assistant) { lastMsg.content data.token } } else if (data.type tool_call) { // 插入 tool call 消息 conversation.messages.push({ role: assistant, tool_calls: [data.tool_call], timestamp: new Date().toISOString() }) } } catch (e) { console.warn(SSE parse error:, e) } } eventSource.value.onerror () { console.error(SSE connection lost) // 自动重连逻辑省略 } } }) onUnmounted(() { if (eventSource.value) { eventSource.value.close() } }) /script3. 离线兜底与降级策略main.js中全局注入网络状态监听// 监听网络状态变化 window.addEventListener(online, () { console.log(Network online, reconnecting...) // 尝试重新连接 SSE // 同步 localStorage 中的离线消息到服务器 }) window.addEventListener(offline, () { console.log(Network offline, entering offline mode) // 禁用所有需要网络的按钮 document.body.classList.add(offline) // 显示离线横幅 ElMessage.warning(网络已断开部分功能受限) }) // 页面加载时检查初始状态 if (!navigator.onLine) { document.body.classList.add(offline) ElMessage.info(当前处于离线模式) }注意事项Vue 的v-model在离线时仍可编辑但