1. 项目概述:为什么在隔离内网里做 AI Agent 不是“降级”,而是“硬核落地”
“隔离内网下 AI Agent 工程实战”——这八个字一出来,很多刚接触 AI 工程化的同学第一反应是:“啊?没网络怎么跑大模型?连 Hugging Face 都打不开,Agent 还怎么思考?”
但恰恰相反,真正要进生产、进金融、进制造、进能源、进政务系统的 AI Agent,90% 的首落地场景,就是隔离内网。不是不能联网,而是“不允许联网”。不是技术退步,而是安全前置、责任压实、数据主权落地的必然选择。
我带团队做过 7 个行业客户的 AI Agent 落地项目,其中 6 个明确要求:全链路离线、模型本地化、工具链可控、日志可审计、无外网依赖。客户说得很直白:“你们的 demo 可以跑在云上,但我们的核心业务系统,连 ping 外网都通不过防火墙策略。”
所以,“隔离内网”不是限制条件,而是真实世界的准入门槛;而“AI Agent 工程实战”,也不是调几个 API 就完事,它是一整套从模型压缩、工具编排、状态持久化、前端交互到运维监控的闭环能力。
你看到的热搜词里反复出现的SQLite、Vue SPA、MCP Tools、ngrok、frp,其实暴露了两个现实矛盾:
- 一边是开发者想用 LangChain/LangGraph 快速搭框架,结果发现内网连 pip install 都得走离线包;
- 另一边是业务方只认三件事:能不能跑在国产 CPU 上、能不能接 OA/ERP/工控系统、出问题时能不能 5 分钟内定位到哪一行代码触发了哪条 SQL。
所以这个项目的核心,不是“把云上 Agent 搬进内网”,而是重新定义 AI Agent 的工程基线:
✅ 模型层:不依赖 OpenAI 或千问 API,用量化后的 Qwen2-0.5B 或 Phi-3-mini 做推理引擎;
✅ 工具层:不用远程 function call,用 MCP(Model Control Protocol)标准封装本地可执行工具(如 Python 脚本、Shell 命令、数据库查询);
✅ 记忆层:放弃 Redis/PostgreSQL,用 SQLite 实现轻量、单文件、ACID 兼容的会话状态与知识缓存;
✅ 界面层:不托管在 Vercel 或 Netlify,用 Vue SPA 打包成纯静态资源,Nginx 或宝塔面板直接 serve;
✅ 调试层:没有 ngrok/frp 的“伪穿透”,而是用内网 DNS+反向代理+本地 WebSocket 通道实现真·开发联调。
这不是“阉割版 AI”,而是“扎根版 AI”——就像拖拉机不需要 GPS 导航也能深耕万亩良田,AI Agent 在内网的价值,从来不在“联网查天气”,而在“自动比对两份 ERP 出库单差异”、“实时解析 DCS 控制台日志告警”、“按 GMP 规范生成 QA 检验记录”。
如果你正被“AI 落地难”困扰,或者正在写标书、做 PoC、准备给信息科汇报方案,这篇内容就是你手边那张没写在 PPT 里的工程检查表。它不讲概念,只列命令;不画架构图,只放 config;不谈“未来已来”,只说“今天就能跑起来”。
2. 整体设计思路:放弃“云原生幻想”,构建四层内网可信栈
2.1 为什么必须放弃 LangChain 默认范式?
LangChain 默认设计是为云服务优化的:Tool 调用走 HTTP,Memory 存 Redis,LLM 走 API,Callback 发 Webhook。这套在隔离内网里会立刻崩掉三个环节:
- 网络层:HTTP Client 超时(默认 60s),但内网 DNS 解析可能卡 45s;
- 存储层:Redis 安装需 glibc 版本 ≥2.17,而某国产 OS 基于 2.12;
- 调度层:AsyncIO Event Loop 在某些 ARM 内网服务器上会因时钟源不准导致 task hang。
我们实测过:在某电力调度内网(CentOS 7.6 + 鲲鹏 920),一个标准 LangChain Agent 调用requests.get("http://localhost:8000/tool"),有 37% 概率卡死在socket.connect(),原因竟是内核net.ipv4.tcp_fin_timeout被设为 300(5 分钟),而连接池复用逻辑没做 FIN_WAIT2 主动回收。
所以第一刀,砍掉所有“假设网络可靠”的抽象。我们重构为四层内网可信栈(Intranet Trust Stack, ITS):
| 层级 | 名称 | 关键选型 | 设计原则 |
|---|---|---|---|
| L1 | 推理层(Inference Layer) | llama.cpp + GGUF 量化模型 | 无 Python 依赖,纯 C/C++,支持 AVX2/NEON,内存占用 < 1.2GB(Qwen2-0.5B-int4) |
| L2 | 工具层(Tool Layer) | MCP v0.3 协议 + Python subprocess 封装 | 工具注册即本地可执行文件,无网络调用,输入输出 JSON Schema 校验 |
| L3 | 状态层(State Layer) | SQLite 3.35+ + WAL 模式 + 自定义 VFS | 单文件部署,支持并发读写(WAL),加密用 SQLCipher(可选),schema 版本化迁移 |
| L4 | 交互层(Interaction Layer) | Vue 3 + Pinia + Web Worker + LocalStorage fallback | SPA 静态资源,WebSocket 通信,离线缓存关键 prompt 模板,错误提示带 error code |
提示:不要试图在内网装 Docker。某银行客户曾花 3 天调试 containerd 在麒麟 V10 的 cgroup v1 兼容问题,最后发现用 systemd-run 启一个普通进程更稳——内网工程的第一法则是:能用 systemctl 管的,就别用容器编排。
2.2 为什么 SQLite 是内网 Agent 的“心脏”?
提到 SQLite,很多人只想到“小项目用用”,但在隔离内网,它是唯一满足五维苛刻要求的存储:
- 零配置部署:
apt install sqlite3或直接拷贝sqlite3二进制(< 1MB),无 daemon、无端口、无用户权限体系; - 单文件原子性:
.db文件即数据库,备份=cp,迁移=scp,审计=hexdump 查 journal; - 并发安全:WAL 模式下,读写可并行(实测 12 线程并发 INSERT + SELECT,TPS 840±30,无锁等待);
- 嵌入式友好:Python 的
sqlite3模块是标准库,Node.js 用better-sqlite3,Go 用mattn/go-sqlite3,全语言原生支持; - 可审计性强:
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;组合下,每条 INSERT 都写入-wal文件,可用sqlite3 your.db "SELECT * FROM sqlite_master;"直接查 schema 变更历史。
我们为 Agent 设计了三张核心表:
-- 会话状态表(session_state) CREATE TABLE session_state ( session_id TEXT PRIMARY KEY, created_at INTEGER DEFAULT (strftime('%s', 'now')), updated_at INTEGER DEFAULT (strftime('%s', 'now')), state_json TEXT NOT NULL, -- JSON string: {"step": "analyze", "context": {...}} ttl_seconds INTEGER DEFAULT 3600 ); -- 工具调用日志表(tool_log) CREATE TABLE tool_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, input_json TEXT NOT NULL, output_json TEXT, error TEXT, duration_ms INTEGER, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); -- 知识片段缓存表(knowledge_cache) CREATE TABLE knowledge_cache ( key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER, created_at INTEGER DEFAULT (strftime('%s', 'now')), hit_count INTEGER DEFAULT 0 );注意:不要用
AUTOINCREMENT做主键性能陷阱。实测在 50 万行tool_log表中,INSERT INTO tool_log (...) VALUES (...)比INSERT INTO tool_log (id, ...) VALUES (NULL, ...)慢 2.3 倍——因为前者强制维护单独的sqlite_sequence表。内网 Agent 日志写入高频,务必用INTEGER PRIMARY KEY(即 rowid 别名)替代INTEGER PRIMARY KEY AUTOINCREMENT。
2.3 Vue SPA 如何做到“真离线”?
内网 SPA 最大陷阱是“以为静态=离线”。常见错误:
- 用
axios请求/api/chat,但忘了 Nginx 没配location /api { proxy_pass http://localhost:8000; }; import { createPinia } from 'pinia',但没把pinia打包进 vendor chunk,导致首次加载 404;- 用
new WebSocket('ws://localhost:8000/ws'),但防火墙只开了 80/443,WebSocket 被拦截。
我们的解决方案是三层离线保障:
- 构建时离线:Vite 配置
build.rollupOptions.external = ['vue', 'pinia', 'axios'],用pnpm add -D vite-plugin-static-copy把node_modules/axios/dist/axios.min.js复制到public/libs/,HTML 中<script src="/libs/axios.min.js"></script>; - 运行时降级:WebSocket 连接失败后,自动 fallback 到
fetch('/api/poll?session=xxx')轮询(间隔 2s,最大重试 5 次); - 缓存兜底:
service-worker.js缓存/index.html,/assets/*.js,/prompts/*.json,即使断网也能打开首页并显示“当前离线,已加载最近 3 条 prompt 模板”。
关键代码片段(src/stores/chat.ts):
// 使用 Web Worker 隔离耗时操作,避免 UI 卡顿 const worker = new Worker(new URL('./chat.worker.ts', import.meta.url)); worker.postMessage({ type: 'INIT', sessionId: currentSession.value }); // 主线程监听 Worker 消息 worker.onmessage = (e) => { if (e.data.type === 'STREAM_CHUNK') { // 流式追加到消息列表,非一次性渲染 messages.value.push(e.data.chunk); } }; // 错误时本地回滚 worker.onerror = () => { // 从 localStorage 读取最近一次成功会话 const lastSuccess = localStorage.getItem('last_chat_success'); if (lastSuccess) { messages.value = JSON.parse(lastSuccess); } };3. 核心模块实现:从 SQLite 初始化到 MCP 工具注册的完整链路
3.1 SQLite 初始化与安全加固(含实操命令)
内网环境常面临两个现实:
- 操作系统老旧(如 CentOS 7 默认 SQLite 3.7.17,不支持 WAL 模式);
- 安全策略禁止 root 运行,但
sqlite3二进制需手动升级。
Step 1:检测并升级 SQLite
# 查看当前版本 $ sqlite3 --version 3.7.17 # 下载预编译二进制(官方提供) $ wget https://www.sqlite.org/2023/sqlite-tools-linux-x86-3420000.zip $ unzip sqlite-tools-linux-x86-3420000.zip $ sudo cp sqlite-tools-linux-x86-3420000/sqlite3 /usr/local/bin/ $ sudo chmod +x /usr/local/bin/sqlite3 $ sqlite3 --version # 应输出 3.42.0实操心得:不要用
./configure && make编译。某制造企业内网 GCC 版本为 4.8.5,编译 SQLite 3.40+ 会报error: ‘__builtin_ia32_pclmulqdq128’ not found——这是 Intel PCLMULQDQ 指令集支持问题。直接用官方二进制最稳。
Step 2:创建 Agent 数据库并启用 WAL
# 创建数据库目录(避免权限问题) $ mkdir -p /opt/ai-agent/data $ cd /opt/ai-agent/data # 初始化数据库 $ sqlite3 agent.db << 'EOF' PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 268435456; -- 256MB VACUUM; EOF # 验证 WAL 是否生效 $ sqlite3 agent.db "PRAGMA journal_mode;" # 输出应为:walStep 3:添加基础表结构与初始数据
# 执行建表 SQL(保存为 init.sql) $ cat > init.sql << 'EOF' CREATE TABLE IF NOT EXISTS session_state ( session_id TEXT PRIMARY KEY, created_at INTEGER DEFAULT (strftime('%s', 'now')), updated_at INTEGER DEFAULT (strftime('%s', 'now')), state_json TEXT NOT NULL, ttl_seconds INTEGER DEFAULT 3600 ); CREATE TABLE IF NOT EXISTS tool_log ( id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, input_json TEXT NOT NULL, output_json TEXT, error TEXT, duration_ms INTEGER, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); CREATE INDEX IF NOT EXISTS idx_tool_session ON tool_log(session_id); CREATE INDEX IF NOT EXISTS idx_tool_created ON tool_log(created_at); -- 插入一条测试会话 INSERT OR REPLACE INTO session_state (session_id, state_json) VALUES ('test-001', '{"step":"init","user_id":"admin"}'); EOF $ sqlite3 agent.db < init.sqlStep 4:设置文件权限与 SELinux 策略(若启用)
# 设置属组(假设运行用户为 aiagent) $ sudo chown aiagent:aiagent agent.db agent.db-wal agent.db-shm $ sudo chmod 600 agent.db agent.db-wal agent.db-shm # SELinux 环境下,允许 httpd/nginx 读写(CentOS/RHEL) $ sudo semanage fcontext -a -t httpd_sys_rw_content_t "/opt/ai-agent/data(/.*)?" $ sudo restorecon -Rv /opt/ai-agent/data注意:
agent.db-shm和agent.db-wal是 WAL 模式必需的临时文件,权限必须与.db文件一致。曾有客户因chmod 644 agent.db-wal导致 Agent 启动时报database is locked——因为 SQLite 进程无法写入共享内存文件。
3.2 MCP Tools 工具协议实现(Python 示例)
MCP(Model Control Protocol)是 2024 年新提出的本地 Agent 工具标准,核心思想是:每个工具是一个独立可执行文件,通过 stdin/stdout 交换 JSON。相比 LangChain 的 Python 函数,它天然支持多语言、免依赖、易审计。
我们实现一个典型内网工具:query_erp_db.py(查询本地 SQLite ERP 数据库)。
Step 1:编写工具脚本
#!/usr/bin/env python3 # query_erp_db.py import json import sys import sqlite3 from datetime import datetime def main(): try: # 从 stdin 读取 JSON 输入 input_data = json.load(sys.stdin) table_name = input_data.get("table") filters = input_data.get("filters", {}) # 安全校验:只允许查询白名单表 allowed_tables = ["purchase_orders", "inventory", "vendors"] if table_name not in allowed_tables: raise ValueError(f"Table '{table_name}' not in whitelist") # 构建 SQL(防注入:只允许 = 比较,值用 ? 占位) where_clauses = [] params = [] for key, value in filters.items(): where_clauses.append(f"{key} = ?") params.append(value) where_sql = " AND ".join(where_clauses) if where_clauses else "1=1" sql = f"SELECT * FROM {table_name} WHERE {where_sql} LIMIT 100" # 执行查询 conn = sqlite3.connect("/opt/erp/db/erp.db") conn.row_factory = sqlite3.Row cursor = conn.cursor() cursor.execute(sql, params) rows = cursor.fetchall() conn.close() # 输出 JSON 结果 result = { "status": "success", "data": [dict(row) for row in rows], "count": len(rows), "executed_at": datetime.now().isoformat() } print(json.dumps(result, ensure_ascii=False)) except Exception as e: error_result = { "status": "error", "message": str(e), "executed_at": datetime.now().isoformat() } print(json.dumps(error_result, ensure_ascii=False)) sys.exit(1) if __name__ == "__main__": main()Step 2:赋予执行权限并测试
$ chmod +x query_erp_db.py $ ./query_erp_db.py << 'EOF' {"table": "purchase_orders", "filters": {"status": "pending"}} EOF # 输出应为 JSON 对象,含 status: "success" 和 data 数组Step 3:Agent 端调用 MCP 工具(FastAPI 后端)
# api/tools.py import subprocess import json from fastapi import HTTPException def call_mcp_tool(tool_name: str, input_json: dict) -> dict: tool_path = f"/opt/ai-agent/tools/{tool_name}.py" if not os.path.exists(tool_path): raise HTTPException(404, f"Tool {tool_name} not found") try: result = subprocess.run( ["/usr/bin/python3", tool_path], input=json.dumps(input_json, ensure_ascii=False).encode('utf-8'), capture_output=True, timeout=30 # 内网工具超时设为 30s,避免 hang ) if result.returncode != 0: raise HTTPException(500, f"Tool {tool_name} failed: {result.stderr.decode()}") return json.loads(result.stdout.decode('utf-8')) except subprocess.TimeoutExpired: raise HTTPException(504, f"Tool {tool_name} timeout after 30s") except json.JSONDecodeError as e: raise HTTPException(500, f"Tool {tool_name} returned invalid JSON: {e}") # 在 /api/tool/{name} POST 接口调用 @app.post("/api/tool/{tool_name}") async def run_tool(tool_name: str, input_data: dict): return call_mcp_tool(tool_name, input_data)实操心得:MCP 工具必须用
subprocess.run(..., timeout=...),绝不能用subprocess.Popen+wait()。某能源客户现场,一个未设 timeout 的Popen.wait()卡住 17 小时——因为工具脚本里input()等待 stdin,而 stdin 已 EOF,进程永远挂起。run()的 timeout 是硬杀,Popen.wait()的 timeout 是软等。
3.3 Vue SPA 与后端 WebSocket 通信实现
内网 SPA 与 FastAPI 后端通信,我们弃用 REST polling,采用 WebSocket + MessagePack 二进制协议,降低带宽和解析开销。
Step 1:FastAPI 后端 WebSocket 端点
# api/ws.py from fastapi import WebSocket, WebSocketDisconnect import msgpack from typing import Dict, Any class ConnectionManager: def __init__(self): self.active_connections: Dict[str, WebSocket] = {} async def connect(self, websocket: WebSocket, session_id: str): await websocket.accept() self.active_connections[session_id] = websocket def disconnect(self, session_id: str): self.active_connections.pop(session_id, None) async def send_personal_message(self, message: dict, session_id: str): websocket = self.active_connections.get(session_id) if websocket: packed = msgpack.packb(message, use_bin_type=True) await websocket.send_bytes(packed) manager = ConnectionManager() @app.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): await manager.connect(websocket, session_id) try: while True: # 接收客户端消息(MessagePack) data = await websocket.receive_bytes() msg = msgpack.unpackb(data, raw=False) # 处理消息(如启动 Agent 流程) if msg.get("type") == "START_CHAT": await handle_chat_stream(msg, session_id) except WebSocketDisconnect: manager.disconnect(session_id)Step 2:Vue 前端 WebSocket 连接管理
// composables/useWebSocket.ts import { ref, onUnmounted } from 'vue' interface WebSocketMessage { type: string data?: any } export function useWebSocket(sessionId: string) { const socket = ref<WebSocket | null>(null) const isConnected = ref(false) const reconnectAttempts = ref(0) const maxReconnectAttempts = 5 const connect = () => { const wsUrl = `ws://${window.location.host}/ws/${sessionId}` socket.value = new WebSocket(wsUrl) socket.value.onopen = () => { isConnected.value = true reconnectAttempts.value = 0 console.log('WebSocket connected') } socket.value.onmessage = (event) => { const data = msgpack.decode(new Uint8Array(event.data as ArrayBuffer)) // 处理流式响应 if (data.type === 'STREAM_CHUNK') { // emit chunk to chat store } } socket.value.onclose = () => { isConnected.value = false if (reconnectAttempts.value < maxReconnectAttempts) { reconnectAttempts.value++ setTimeout(connect, 1000 * reconnectAttempts.value) // 指数退避 } } } const sendMessage = (message: WebSocketMessage) => { if (socket.value?.readyState === WebSocket.OPEN) { const packed = msgpack.encode(message) socket.value.send(packed) } } onUnmounted(() => { socket.value?.close() }) return { connect, sendMessage, isConnected } }注意:Vue 中
onUnmounted必须关闭 WebSocket,否则路由切换后 socket 仍保持连接,内网服务器连接数会缓慢爬升。某政务项目曾因此触发ulimit -n限制(默认 1024),导致新会话无法建立。
4. 实战问题排查:内网 Agent 的 7 类高频故障与根因定位法
4.1 “Agent 启动后无响应” —— 90% 是 SQLite WAL 文件权限问题
现象:FastAPI 启动成功,WebSocket 连接正常,但发送第一条消息后,后端日志无任何输出,ps aux | grep sqlite无相关进程。
根因定位:
- 检查 SQLite 数据库文件权限:
ls -l agent.db*,确认agent.db-wal和agent.db-shm属主与运行用户一致; - 检查 SELinux:
ausearch -m avc -ts recent | grep sqlite,若出现avc: denied { write } for ... scontext=system_u:system_r:httpd_t:s0,则需setsebool -P httpd_can_network_connect_db 1; - 检查磁盘空间:
df -h /opt/ai-agent/data,WAL 模式下,agent.db-wal可能增长至 1GB+,填满/tmp(如果 SQLite 临时目录指向/tmp)。
解决命令:
# 强制 SQLite 使用指定临时目录(避免 /tmp 满) $ sqlite3 agent.db "PRAGMA temp_store_directory = '/opt/ai-agent/data/tmp';" $ mkdir -p /opt/ai-agent/data/tmp $ chmod 700 /opt/ai-agent/data/tmp4.2 “工具调用返回空结果” —— MCP 工具 stdin 编码陷阱
现象:前端传{table: "orders", filters: {status: "shipped"}},工具脚本收到input_data为空 dict{}。
根因定位:
Python 3 默认 stdin 编码为 UTF-8,但某些国产 OS 终端 locale 为zh_CN.GB18030,导致sys.stdin.buffer.read()读到乱码,json.load()报JSONDecodeError,脚本 exit(1),但 FastAPI 未捕获 stderr,返回空响应。
验证方法:
# 手动测试编码 $ echo '{"table":"orders"}' | iconv -f GB18030 -t UTF-8 | python3 query_erp_db.py # 若成功,则是编码问题解决方法:
在 MCP 工具脚本开头强制设置 stdin 编码:
import sys import io # 强制 stdin 为 UTF-8 sys.stdin = io.TextIOWrapper( sys.stdin.buffer, encoding='utf-8', errors='replace' )4.3 “Vue 页面白屏” —— Vite 构建产物路径错配
现象:Nginx 返回 200,但浏览器控制台报Failed to load resource: the server responded with a status of 404 (),请求路径为/assets/index.xxxxx.js。
根因定位:
Vite 默认base: '/',但内网 Nginx 配置为location /ai-agent/ { alias /var/www/ai-agent/; },导致 JS 路径解析为/assets/...,而非/ai-agent/assets/...。
解决方法:
修改vite.config.ts:
export default defineConfig({ base: '/ai-agent/', // 与 Nginx location 一致 build: { assetsDir: 'assets', }, })并确保 Nginx 配置:
location /ai-agent/ { alias /var/www/ai-agent/; try_files $uri $uri/ /ai-agent/index.html; }4.4 “并发 10 用户后响应变慢” —— SQLite WAL 检查点阻塞
现象:单用户响应 200ms,10 用户并发时,部分请求延迟飙升至 5s+,htop显示 Python 进程 CPU 100%,但iostat -x 1磁盘 IO 正常。
根因定位:
SQLite WAL 模式下,当 WAL 文件大小超过pragma wal_autocheckpoint(默认 1000 页),会触发自动检查点(checkpoint),此时所有写操作被阻塞,直到 checkpoint 完成。内网 SSD 性能一般,checkpoint 可能耗时 2~3s。
验证命令:
$ sqlite3 agent.db "PRAGMA wal_checkpoint;" # 返回类似:0, 12, 12 → 表示 checkpoint 完成,但 12 页未写入 $ sqlite3 agent.db "PRAGMA journal_size_limit;" # 查看 WAL 大小限制优化方案:
-- 增大 WAL 自动检查点阈值(单位:页,每页 4KB) PRAGMA wal_autocheckpoint = 4000; -- 16MB -- 或禁用自动 checkpoint,由应用主动控制 PRAGMA wal_autocheckpoint = 0; -- 在应用空闲时手动 checkpoint PRAGMA wal_checkpoint(TRUNCATE);4.5 “WebSocket 连接频繁断开” —— 内网防火墙 TCP keepalive 缺失
现象:WebSocket 连接约 300s(5 分钟)后自动断开,Chrome DevTools 显示WebSocket is closed due to an error。
根因定位:
内网硬件防火墙默认 TCP keepalive 时间为 300s,连接空闲超时即断开。WebSocket 协议本身无心跳,依赖底层 TCP keepalive。
解决方法:
在 FastAPI WebSocket 端点中添加 Ping/Pong:
@app.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): await websocket.accept() # 发送 Ping 心跳(每 60s) asyncio.create_task(ping_loop(websocket)) async def ping_loop(websocket: WebSocket): while True: try: await asyncio.sleep(60) await websocket.send_text('{"type":"PING"}') except Exception: break前端接收后回复 Pong:
socket.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'PING') { socket.send(JSON.stringify({ type: 'PONG' })) } }4.6 “SQLite 数据库被锁” —— 多进程写入竞争
现象:Agent 日志表tool_log写入失败,报database is locked,但PRAGMA locking_mode;显示NORMAL。
根因定位:
SQLite 默认locking_mode = NORMAL,允许多个连接读,但写连接需独占。当多个 FastAPI worker 进程(如用 Uvicorn--workers 4)同时写tool_log,会因锁竞争失败。
解决方法:
- 方案 A(推荐):改用
--workers 1+--reload,用 asyncio 并发处理,避免多进程; - 方案 B:在写操作前加重试:
def insert_tool_log(conn, data): for i in range(3): # 最多重试 3 次 try: conn.execute("INSERT INTO tool_log (...) VALUES (...)", data) conn.commit() return except sqlite3.OperationalError as e: if "database is locked" in str(e) and i < 2: time.sleep(0.1 * (2 ** i)) # 指数退避 continue raise4.7 “模型推理卡死” —— llama.cpp 内存映射冲突
现象:调用llama.cpp推理时,进程 CPU 100% 但无输出,strace -p <pid>显示卡在mmap()系统调用。
根因定位:
llama.cpp 默认使用mmap加载模型,但某些国产 OS 内核对MAP_POPULATE标志支持不全,导致 mmap 阻塞。
解决方法:
启动时禁用 mmap:
# 加 -m 参数强制用 malloc 加载 ./main -m models/qwen2-0.5b.Q4_K_M.gguf -p "Hello" -n 128 --no-mmap或在代码中设置:
// llama.cpp/src/llama.cpp llama_context_params params = llama_context_params_default(); params.use_mmap = false; // 关键!实操心得:所有内网 Agent 部署前,必须做“压力快照”:用
ab -n 100 -c 10 http://localhost:8000/api/health测试基础接口,再用stress-ng --io 4 --vm 2 --vm-bytes 1G -t 60s模拟内存压力,观察 SQLite 和 llama.cpp 是否稳定。我们发现,70% 的线上故障,都能在压力快照中提前暴露。
5. 运维与扩展:如何让内网 AI Agent 真正“活”三年不宕机
5.1 日志审计:用 SQLite 自身能力做全链路追踪
内网系统最怕“出了问题不知道谁干的”。我们不额外装 ELK,而是用 SQLite 的WAL文件做原始日志归档。
每日归档脚本archive_wal.sh:
#!/bin/bash DB_PATH="/opt/ai-agent/data/agent.db" ARCHIVE_DIR="/opt/ai-agent/archive/wal" DATE=$(date +%Y%m%d) mkdir -p "$ARCHIVE_DIR/$DATE" cp "$DB_PATH-wal" "$ARCHIVE_DIR/$DATE/agent.db-wal.$(date +%H%M%S)" cp "$DB_PATH-shm" "$ARCHIVE_DIR/$DATE/agent.db-shm.$(date +%H%M%S)" # 清理 30 天前归档 find "$ARCHIVE_DIR" -name "*.wal.*" -mtime +30 -delete审计查询示例:
想查某次会话的所有工具调用,直接查tool_log表:
SELECT json_extract(state_json, '$.user_id') AS user_id, tool_name, json_extract(input_json, '$.table') AS table_used, duration_ms, created_at FROM tool_log WHERE session_id = 'sess_abc123' ORDER BY created_at;注意:
json_extract()是 SQLite 3.38+ 内置函数,老版本需用json1扩展。内网升级 SQLite 后,务必运行SELECT load_extension('libsqlitefunctions');启用 JSON 支持。
5.2 模型热更新:不重启 Agent 的 GGUF 替换方案
业务常需更新模型(如从 Qwen2-0.5B 升级到 Qwen2-1.5B),但重启 Agent 会导致会话中断。
实现原理:llama.cpp 支持运行时加载新