59 KiB
家庭多模态智能监控系统 — 软件设计说明书
版本: v1.0 日期: 2026-08-19 目标: 多模型并行 + 交互式命名 + AI 对话的家庭监控系统
1. 设计目标与范围
1.1 首期范围(In Scope)
- FAM-Core 单进程,承载 Task-Scheduler / Dispatcher / Event-Receiver / Chat-Handler / Member-Manager / Video-Server 六个子模块
- FAM-Edge 单进程,承载视频下载 → FFmpeg 抽帧 → 本地 VLM 视觉分析 + Gemini 云端增强 → 本地 VLM 文本融合 → 回调 全链路
- FAM-UI 用 Streamlit 直读 DB,展示事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
- 数据库六张表:process_tasks / monitor_events / daily_summaries(预留) / event_details / chat_history / family_members
- 任务状态机:PENDING → PROCESSING → SUCCESS/FAILED,含重试
- AI 对话:用户问"汤圆今天干嘛了",FAM-Core 查 event_details 拼上下文 → 调 Oracle Ollama 纯文本 → 返回回答并写 chat_history
- 交互式成员命名:VLM 自动按特征提取"人物A/B/C"落库,用户在 FAM-UI 命名为"汤圆/妈妈"后,批量回溯更新历史记录,后续视频分析直接使用真实名字
1.2 不在首期范围(Out of Scope,推迟 v1.1+)
- Nginx 静态服务(用 Flask
send_from_directory替代) - 资源保护策略 B(CPU/内存过载返回 503,单并发下不必要)
- 心跳监控(Heartbeat-Monitor,单任务串行下用整体超时兜底)
- 成员特征历史快照(members_snapshot_json 字段)
- API 业务接口鉴权(
/api/edge/*和/api/core/*不加鉴权;仅 Video-Server 的/media/*加 token,因为视频文件是敏感数据) - Cron 兜底清理(finally 清理足够,Cron 推后)
1.3 仍保留的"非可选"埋点
即使首期也要做的,否则后期回头补很痛:
task_id作为 trace_id,NAS 与 Oracle 两端日志必须带- 各阶段耗时落日志(download / extract / vlm_visual / vlm_fusion / callback)
failure_stage字段落库(原 DDL 已有,保留)- Video-Server 路由带
?token=xxx简单鉴权
2. 系统架构
2.1 物理节点与部署
三台物理/虚拟节点,通过 Tailscale 虚拟局域网互联(100.x.x.x/24 网段)。
| 节点 | 角色 | 硬件 | IP 地址 | 部署服务 | 监听端口 |
|---|---|---|---|---|---|
| Synology NAS | FAM-Core + FAM-UI + 数据库 | NAS 自带资源 | Tailscale: 100.x.x.10 家庭局域网: 192.168.50.64 |
FAM-Core (Flask)、FAM-UI (Streamlit)、MariaDB、Surveillance Station | 8000 (FAM-Core)、8501 (Streamlit)、3306 (MariaDB) |
| Oracle Cloud | FAM-Edge | 2C12G, ARM Ampere A1, 无 GPU | Tailscale: 100.x.x.20 公网: 129.146.203.203 |
FAM-Edge (Flask)、Ollama、FFmpeg | 5000 (FAM-Edge)、11434 (Ollama) |
| 家庭网络 | 管理员/用户入口 | 普通终端 | 192.168.50.0/24 内 | 浏览器访问 FAM-UI | — |
IP 访问约束:
- Oracle ↔ NAS 的服务间通信一律走 Tailscale(100.x.x.10 / 100.x.x.20),不走公网,不走家庭局域网
- Oracle FAM-Edge 监听
0.0.0.0:5000,但安全组仅放行 Tailscale 入站;公网 129.146.203.203:5000 由安全组拦截 - NAS FAM-Core 监听
0.0.0.0:8000,Video-Server 路由靠?token=xxx鉴权;Tailscale 入站自由,家庭局域网 192.168.50.64:8000 也可达 - 家庭网络用户访问 FAM-UI 走
http://192.168.50.64:8501(局域网直连,不经 Tailscale)
2.2 部署拓扑图
┌──────────────────────────────────────────────────────────────────────────┐
│ Tailscale 虚拟局域网 (100.x.x.x/24) │
│ │
│ ┌──────────────────────────────────┐ ┌──────────────────────────┐ │
│ │ Synology NAS (FAM-Core) │ │ Oracle Cloud (FAM-Edge)│ │
│ │ 硬件: NAS 自带 │ │ 硬件: 2C12G ARM A1 │ │
│ │ Tailscale: 100.x.x.10 │ │ Tailscale: 100.x.x.20 │ │
│ │ 家庭局域网: 192.168.50.64 │ │ 公网: 129.146.203.203 │ │
│ │ (公网入站: 无, 仅出站) │ │ (公网入站: 仅 Tailscale)│ │
│ │ │ │ │ │
│ │ ┌────────────────────────────┐ │ │ ┌────────────────────┐ │ │
│ │ │ Surveillance Station │ │ │ │ FAM-Edge (Flask) │ │ │
│ │ │ - 录制视频 │ │ │ │ 端口: 5000 │ │ │
│ │ │ - 输出 mp4 至挂载目录 │ │ │ │ - Video-Preprocess │ │ │
│ │ └───────────┬────────────────┘ │ │ │ - AI-Orchestrator │ │ │
│ │ │ │ │ │ - Storage-Cleaner │ │ │
│ │ ▼ │ │ └─────────┬──────────┘ │ │
│ │ ┌────────────────────────────┐ │ │ │ │ │
│ │ │ NAS 挂载目录 │ │ │ ▼ │ │
│ │ │ /volume1/surveillance/ │◄─┼───────┼── HTTP GET /media/*.mp4 │ │
│ │ └───────────┬────────────────┘ │ │ (Tailscale, ?token) │ │
│ │ │ │ │ │ │
│ │ ▼ │ │ ┌────────────────────┐ │ │
│ │ ┌────────────────────────────┐ │ │ │ Ollama (systemd) │ │ │
│ │ │ FAM-Core (Flask) │ │ │ │ 端口: 11434 │ │ │
│ │ │ 端口: 8000 (Tailscale) │ │ │ │ 模型: llava-phi3 │ │ │
│ │ │ - Task-Scheduler │──┼───────┼─► HTTP POST 下发任务 │ │
│ │ │ - Dispatcher │◄─┼───────┼── HTTP POST 回调 │ │
│ │ │ - Event-Receiver │ │ │ │ │
│ │ │ - Video-Server │ │ │ ┌────────────────────┐ │ │
│ │ └───────────┬────────────────┘ │ │ │ FFmpeg + OpenCV │ │ │
│ │ │ │ │ │ (系统级二进制) │ │ │
│ │ ▼ │ │ └────────────────────┘ │ │
│ │ ┌────────────────────────────┐ │ │ ▲ │ │
│ │ │ MariaDB │ │ │ │ │ │
│ │ │ 端口: 3306 (本地) │◄─┼───────┼── HTTP 回调 (POST) │ │
│ │ │ - process_tasks │ │ 写入 │ │ task_id + 结果 JSON │ │
│ │ │ - monitor_events │ │ │ └────────────────────┘ │ │
│ │ │ - daily_summaries │ │ └──────────────────────────┘ │
│ │ └───────────┬────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ ┌──────────────┐ │
│ │ ┌────────────────────────────┐ │ │ 家庭网络用户 │ │
│ │ │ FAM-UI (Streamlit) │◄─┼───────────│ 浏览器 │ │
│ │ │ 端口: 8501 (局域网) │ │ 192.168. │ 192.168.50.x │ │
│ │ │ - Event-List │ │ 50.64:8501└──────────────┘ │
│ │ │ - Filter-Bar │ │ │
│ │ │ - Stats-Chart │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
2.3 网络流与数据流
| # | 流向 | 协议 | 内容 | 触发方 |
|---|---|---|---|---|
| 1 | SS → NAS 挂载目录 | 文件系统 | mp4 视频落盘 | Surveillance Station 定时录制 |
| 2 | NAS → NAS | 进程内 | Task-Scheduler 扫描目录,写 process_tasks | FAM-Core 内部 |
| 3 | NAS → Oracle | HTTP POST | Dispatcher 下发任务 (task_id + video_url + webhook) | FAM-Core |
| 4 | Oracle → NAS | HTTP GET | FAM-Edge 拉取 mp4 视频 | FAM-Edge |
| 5 | Oracle → Oracle | HTTP | FAM-Edge 调用 Ollama (视觉分析 + 文本融合) | FAM-Edge |
| 6 | Oracle → NAS | HTTP POST | FAM-Edge 回调 Event-Receiver,写 monitor_events | FAM-Edge |
| 7 | 浏览器 → NAS | HTTP | 访问 Streamlit UI | 家庭网络用户 |
2.4 端口与目录清单
NAS 端:
| 服务 | 端口 | 数据目录 | 配置文件 |
|---|---|---|---|
| FAM-Core | 8000 | — | config/config.yaml(成员特征改由 family_members 表管理) |
| MariaDB | 3306 | /volume1/@database/mysql/ |
MariaDB 默认配置 |
| FAM-UI | 8501 | — | config/config.yaml (复用) |
| Surveillance Station | (SS 默认) | /volume1/surveillance/ |
SS 控制台 |
| Video-Server 路由 | 8000 (复用) | /volume1/surveillance/ |
— |
Oracle 端:
| 服务 | 端口 | 工作目录 | 说明 |
|---|---|---|---|
| FAM-Edge | 5000 | /opt/fam-edge/ |
应用代码 |
| Ollama | 11434 | ~/.ollama/ |
模型存储 |
| FFmpeg | — | /tmp/fam_media/task_<id>*/ |
临时帧图片,任务结束清理 |
2.5 与原文档的差异
| 章节 | 原设计 | 首期 | 理由 |
|---|---|---|---|
| 1.1 架构图 | Nginx 标"可选" | Flask send_from_directory 替代 |
首期并发 1,无需 Nginx |
| 1.2 技术选型 | Gemini 1.5 Flash(可选增强) | 首期纳入,与本地 VLM 并行调用 | 云端增强 + 交叉验证 |
| 2.1 FAM-Core | 未列 Video-Server | 加为子模块 | 主链路必需 |
| 2.1 FAM-Core | Heartbeat-Monitor | 首期不做 | 单任务串行,整体超时兜底 |
| 2.2 FAM-Edge | Local-LLM-Runtime 子模块 | 首期不单独管理 Ollama | 靠 systemd 托管 + /api/tags 探测,挂了 503 |
| 2.2 FAM-Edge | Storage-Cleaner 子模块 + Cron 兜底 | 仅 finally 清理 |
首期任务量小,Cron 推后 |
| 2.3 FAM-UI | 成员管理页(缺失) | 首期纳入,新增 Member-Naming 页 | 交互式命名流程,用户给"人物A/B"赋真名 |
| 2.4 熔断器 | CircuitBreaker | 首期纳入 | Gemini 连续失败跳过调用,冷却后探测恢复 |
| 2.5 资源保护 | 策略 B(503) | 首期不做 | 单并发下无意义 |
| DB | family_members 表(缺失) | 首期新增 | 支持交互式命名,VLM 输出 abstract_label,用户命名后批量回溯更新 |
| API payload | known_members_context 字符串 |
改为注入已命名+未命名成员清单 | NAS 端从 family_members 表读取后拼接 |
3. 模块设计
3.1 FAM-Core(NAS 端,单进程)
| 模块 | 职责 | 首期简化 |
|---|---|---|
| Task-Scheduler | 60s 轮询视频目录,创建 PENDING 任务 | is_complete 判定简化为"修改时间 > 60s 且文件大小稳定" |
| Dispatcher | 30s 轮询 PENDING 任务,下发至 Edge | 退避重试保留,但所有 stage 一视同仁;payload 注入已命名成员清单 |
| Event-Receiver | Flask 路由,接收 Edge 回调,写库 | 单 worker,不做幂等锁;接收 frame_details 数组,逐条插入 event_details;对未命名的 abstract_label 自动 upsert 到 family_members |
| Chat-Handler | Flask 路由,接收用户问答 | 查 event_details 拼上下文 → 调 Oracle Ollama 纯文本 → 写 chat_history |
| Member-Manager | Flask 路由,成员命名管理 | 列出未命名人物;接收命名请求;批量 UPDATE event_details 回溯历史 |
| Video-Server | Flask send_from_directory,提供 mp4 静态下载 |
路由带 ?token=xxx 鉴权 |
3.2 FAM-Edge(Oracle 端,单进程)
| 模块 | 职责 | 首期简化 |
|---|---|---|
| API-Gateway | 接收任务,并发控制 | 同时只允许 1 个任务在处理;新任务到达时若当前有任务处理中,返回 429 |
| Video-Preprocessor | 下载视频 + FFmpeg 粗抽帧 + OpenCV 关键帧筛选 + 压缩 | 下载超时 60s;粗抽 30 张候选帧;筛 5-8 张关键帧;长边 ≤ 1024px,JPEG 质量 80 |
| AI-Orchestrator | 调用 Ollama 视觉分析 + Gemini 视觉分析 + Ollama 文本融合 | Gemini 异步并行调用,超时 8s 不阻塞;熔断器保护;失败降级 local_only |
| Storage-Cleaner | finally 删除下载的视频和帧图片 |
不做 Cron 兜底 |
3.3 FAM-UI(NAS 端)
| 模块 | 职责 | 首期简化 |
|---|---|---|
| Event-List | 分页展示事件 | 按时间倒序,每页 20 条 |
| Filter-Bar | 按日期筛选 | 仅日期,不做事件类型筛选 |
| Stats-Chart | compute_provider 占比 | 用 Streamlit 内置 bar_chart |
| Chat-Page | AI 对话页签 | 输入框 + 调用 POST /api/chat/ask + 展示回答;按 queried_person 预设快捷提问按钮 |
| Chat-History | 对话历史列表 | 按 chat_history.created_at 倒序,每页 20 条 |
| Member-Naming | 成员命名页 | 列出 family_members 中 real_name IS NULL 的未命名人物,展示特征描述,输入框命名后调 POST /api/member/name;命名后展示已命名成员列表 |
4. 数据流(首期主链路)
4.1 时序
- NAS 发现新视频 → 写入
process_tasks(PENDING) - Dispatcher 下发任务至 Oracle (
POST /api/edge/video/analyze) - Oracle 返回 202 → NAS 更新状态为 PROCESSING
- Oracle 内部:
- a. 下载视频(超时 60s)
- b. FFmpeg 粗抽 30 张候选帧 + OpenCV 关键帧筛选(5-8 张)+ 压缩
- c. 并行启动:本地 VLM 多模态视觉分析(5-8 张关键帧,超时 240s)+ Gemini 云端视觉分析(超时 8s,可选)
- d. 等待 Gemini 结果(最多 8s,超时或熔断则
gemini_output=None,不阻塞) - e. 本地 VLM 纯文本融合(VLM_OUTPUT + GEMINI_OUTPUT,超时 120s)
- f. 回调 NAS(超时 30s,失败重试 3 次)
- g. 清理临时文件
- NAS 收到回调 → 写入
monitor_events(含compute_provider: hybrid或local_only),更新任务 SUCCESS - UI 展示
4.2 与原文档的差异
- 删除心跳发送步骤
- 整体超时仍为 600s,由 Edge 端内部定时器控制
- 保留并行 Gemini 调用(首期纳入),熔断器保护,失败降级 local_only
5. 数据库设计
5.1 DDL(与原文档一致)
-- 任务表
CREATE TABLE process_tasks (
task_id INT AUTO_INCREMENT PRIMARY KEY,
video_path VARCHAR(500) NOT NULL,
video_url VARCHAR(500) NOT NULL,
status ENUM('PENDING','PROCESSING','SUCCESS','FAILED') DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
next_retry_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
error_message TEXT NULL,
failure_stage ENUM('download','extract','vlm_visual','vlm_fusion','callback') NULL,
heartbeat_at DATETIME NULL,
INDEX idx_status (status),
INDEX idx_created (created_at)
) ENGINE=InnoDB;
-- 事件表
CREATE TABLE monitor_events (
event_id INT AUTO_INCREMENT PRIMARY KEY,
task_id INT NOT NULL,
event_start_time DATETIME NOT NULL,
event_end_time DATETIME NOT NULL,
camera_name VARCHAR(50),
global_summary TEXT,
entities_json JSON NOT NULL,
compute_provider JSON NOT NULL, -- 模型来源数组,如 ["ollama","gemini"]
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event_start (event_start_time),
INDEX idx_compute_provider (compute_provider),
FOREIGN KEY (task_id) REFERENCES process_tasks(task_id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 每日摘要表(预留)
CREATE TABLE daily_summaries (
id INT AUTO_INCREMENT PRIMARY KEY,
target_date DATE NOT NULL UNIQUE,
summary_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 事件明细表(每关键帧一条,由 VLM 输出 frame_details 拆分落库)
CREATE TABLE event_details (
detail_id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL, -- 关联 monitor_events.event_id
task_id INT NOT NULL, -- 冗余,便于查询
frame_index INT NOT NULL, -- 关键帧序号(1-8)
frame_timestamp DATETIME NOT NULL, -- 该帧对应的绝对时间点
camera_name VARCHAR(50), -- 摄像头位置(如"客厅")
person VARCHAR(50) NOT NULL, -- 成员名或"未知访客"
action VARCHAR(200) NOT NULL, -- 动作描述(AI 自由生成,无枚举)
clothing VARCHAR(100), -- 衣着
is_attention_event BOOLEAN DEFAULT FALSE, -- 由 AI 自行判断是否为关注事件,无固定清单
source_providers JSON NOT NULL, -- 识别到该明细的模型来源数组,如 ["ollama","gemini"]
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event_id (event_id),
INDEX idx_frame_timestamp (frame_timestamp),
INDEX idx_person (person),
INDEX idx_queried_date (frame_timestamp),
FOREIGN KEY (event_id) REFERENCES monitor_events(event_id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 对话记录表(用户与 AI 问答的完整记录)
CREATE TABLE chat_history (
chat_id INT AUTO_INCREMENT PRIMARY KEY,
user_question TEXT NOT NULL, -- 用户问题
ai_answer TEXT NOT NULL, -- AI 回答
context_summary TEXT, -- 问答时使用的上下文摘要(可选)
queried_date DATE, -- 查询的日期范围
queried_person VARCHAR(50), -- 查询的人物
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created (created_at),
INDEX idx_queried_date (queried_date)
) ENGINE=InnoDB;
-- 家庭成员表(交互式命名)
CREATE TABLE family_members (
member_id INT AUTO_INCREMENT PRIMARY KEY,
abstract_label VARCHAR(20) NOT NULL UNIQUE, -- VLM 首次提取的抽象标识,如 "人物A"
real_name VARCHAR(50), -- 用户命名的真实名字,如 "汤圆";NULL 表示未命名
feature_description TEXT, -- VLM 提取的特征描述,如 "短发、黄色T恤、男性"
first_seen_at DATETIME, -- 首次被提取的时间
named_at DATETIME NULL, -- 用户命名的时间
named_by VARCHAR(50), -- 命名人
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_real_name (real_name),
INDEX idx_abstract_label (abstract_label)
) ENGINE=InnoDB;
5.2 表关系
process_tasks (1) ─── (N) monitor_events (1) ─── (N) event_details
│
└─ event_id 外键
family_members 独立表
- event_details.person 存 family_members.abstract_label(未命名时)或 real_name(命名后)
- 命名后批量 UPDATE event_details SET person = real_name WHERE person = abstract_label
chat_history 独立表,不与上述表强关联
5.3 compute_provider 取值(多模型场景)
monitor_events.compute_provider 记录本次任务调用了哪些模型,JSON 数组字符串:
| 场景 | 取值示例 | 说明 |
|---|---|---|
| 仅本地 Ollama 成功 | ["ollama"] |
Gemini 超时/失败/熔断 |
| Ollama + Gemini 均成功 | ["ollama","gemini"] |
首期典型场景 |
| 加 OpenAI(v1.1) | ["ollama","gemini","openai"] |
扩展场景 |
| 全部失败 | 任务 FAILED | 不写入 monitor_events |
字段类型调整:compute_provider 由原 ENUM 改为 JSON 数组,便于动态扩展。原 ENUM('hybrid','local_only') 已不适用多模型场景。
event_details.source_providers 与 monitor_events.compute_provider 的关系:
compute_provider记录本次任务级别调用了哪些模型(数组去重)source_providers记录该条 frame_detail 被哪些模型识别到(可能少于 compute_provider,例如某帧 Gemini 没识别到人物但 Ollama 识别了)
6. API 规范
6. API 规范
6.1 POST /api/edge/video/analyze(Edge 接收任务)
请求体:
{
"task_id": 1001,
"video_url": "http://100.x.x.10:8000/media/video_171500.mp4?token=xxx",
"webhook_url": "http://100.x.x.10:8000/api/core/callback/event",
"known_members_context": "成员A:特征描述; 成员B:特征描述"
}
响应:
- 202 Accepted:任务已入队
- 429 Too Many Requests:队列已满(>1 个等待)
- 503 Service Unavailable:Ollama 不可用
6.2 POST /api/core/callback/event(NAS 接收回调)
成功请求体:
{
"task_id": 1001,
"status": "success",
"event_start_time": "2026-08-19T14:00:00Z",
"event_end_time": "2026-08-19T14:30:00Z",
"camera_name": "客厅",
"global_summary": "客厅有人员走动,汤圆在玩积木,期间跌倒一次。",
"entities_json": [
{"person": "汤圆", "action": "在地毯上玩积木", "clothing": "黄色T恤"}
],
"frame_details": [
{
"frame_index": 1,
"frame_timestamp": "2026-08-19T14:03:00Z",
"person": "汤圆",
"action": "在地毯上玩积木",
"clothing": "黄色T恤",
"is_attention_event": false
},
{
"frame_index": 5,
"frame_timestamp": "2026-08-19T14:25:00Z",
"person": "汤圆",
"action": "跌倒",
"clothing": "黄色T恤",
"is_attention_event": true
}
],
"compute_provider": "local_only",
"error_message": null
}
Event-Receiver 处理逻辑:
- 插入
monitor_events1 条(聚合记录,含 global_summary / entities_json / camera_name) - 遍历
frame_details数组,逐条插入event_details(条数无上限,AI 输出多少插多少;action由 AI 自由生成,无枚举过滤) - 更新
process_tasks状态为 SUCCESS
失败请求体:
{
"task_id": 1001,
"status": "failed",
"failure_stage": "vlm_visual",
"error_message": "VLM inference timeout"
}
响应:200 OK
6.3 GET /media/<path:filename>(Video-Server 静态服务)
- 带鉴权:
?token=xxx,token 从 NAS 端配置读取 - 无 token 或 token 错误返回 403
- 文件不存在返回 404
6.4 POST /api/chat/ask(NAS 接收用户问答)
请求体:
{
"question": "汤圆今天干嘛了?",
"queried_person": "汤圆",
"queried_date": "2026-08-19"
}
响应(200 OK):
{
"answer": "根据今日监控数据,汤圆主要活动如下:\n14:03 在客厅地毯上玩积木\n14:25 在客厅跌倒一次(关注事件)\n15:10 在客厅看绘本\n...",
"context_summary": "查询 event_details 12 条,时间范围 14:03-18:30",
"chat_id": 5001
}
Chat-Handler 处理逻辑:
- 根据
queried_person和queried_date查询event_detailsSELECT frame_timestamp, camera_name, person, action, clothing, is_attention_event FROM event_details WHERE person = :queried_person AND DATE(frame_timestamp) = :queried_date ORDER BY frame_timestamp ASC - 拼接上下文(每条明细一行:
[14:03 客厅] 汤圆 在地毯上玩积木 (黄色T恤)) - 若明细条数 > 50,按小时聚合成 24 行摘要再喂给 VLM(避免超 llava-phi3 上下文 2000 字)
- POST Oracle Ollama 纯文本模式,调问答 Prompt
- 插入
chat_history(user_question + ai_answer + context_summary + queried_date + queried_person) - 返回回答
问答 Prompt(中文):
你是家庭监控助手。根据以下今日监控数据,回答用户问题。
今日数据(按时间顺序,每条一行):
[14:03 客厅] 汤圆 在地毯上玩积木 (黄色T恤)
[14:25 客厅] 汤圆 跌倒 (黄色T恤) [关注事件]
[15:10 客厅] 汤圆 看绘本 (黄色T恤)
...
已知家庭成员: 汤圆(特征描述)
用户问题: {question}
要求:
- 只基于上述数据回答,不要编造
- 按时间顺序总结
- 若有关注事件(跌倒、哭闹等),重点提示
- 若当天没有该人员的数据,明确说"今天没有观察到{person}"
- 用自然语言回答,不要输出 JSON
6.5 GET /api/chat/history(获取对话历史,可选)
查询参数:?date=2026-08-19 或 ?person=汤圆&limit=20
响应:按 chat_history.created_at 倒序返回对话列表。
6.6 GET /api/member/unnamed(列出未命名人物)
响应(200 OK):
{
"unnamed_members": [
{
"abstract_label": "人物A",
"feature_description": "短发、黄色T恤、男性",
"first_seen_at": "2026-08-19T14:03:00Z",
"event_count": 15
},
{
"abstract_label": "人物B",
"feature_description": "长发、红色裙子、女性",
"first_seen_at": "2026-08-19T15:10:00Z",
"event_count": 8
}
]
}
6.7 POST /api/member/name(命名人物)
请求体:
{
"abstract_label": "人物A",
"real_name": "汤圆",
"named_by": "管理员"
}
Member-Manager 处理逻辑:
- 校验
abstract_label存在于family_members且real_name IS NULL - UPDATE
family_membersSETreal_name=:real_name, named_at=NOW(), named_by=:named_byWHEREabstract_label=:abstract_label - 批量回溯更新历史记录:
UPDATE event_details SET person = :real_name WHERE person = :abstract_label; UPDATE monitor_events SET entities_json = JSON_REPLACE(entities_json, '$[*].person', :real_name) WHERE entities_json LIKE :abstract_label_pattern; - 响应(200 OK):
{ "abstract_label": "人物A", "real_name": "汤圆", "updated_event_details_count": 15, "updated_monitor_events_count": 8 }
6.8 GET /api/member/list(列出所有成员)
查询参数:?include_named=true&include_unnamed=true
响应:返回 family_members 全表,按 first_seen_at 升序。
7. 关键技术
7.1 视频预处理(关键帧筛选)
不再使用等距抽 30 帧的方案,改为关键帧筛选,将 VLM 输入图片数量从 30 降到 5-8 张。
两步流程:
步骤 1:等距粗抽帧
ffprobe获取视频时长(秒)- 计算
interval = duration / 30 ffmpeg -vf fps=1/{interval}抽出 30 张候选帧
步骤 2:帧差分析筛选关键帧
- 用 OpenCV 对 30 张候选帧两两计算 MSE(均方误差)或 SSIM(结构相似性)
- 筛选规则(按优先级):
- 首帧必选(保留时间起点)
- 末帧必选(保留时间终点)
- 差异最大的 N 帧:遍历 30 张,保留与上一关键帧 MSE > 阈值(默认 500)的帧
- 若筛选结果 < 5 帧,从剩余候选中按时间均匀补足到 5 帧
- 若筛选结果 > 8 帧,按差异值降序取前 8 帧
- 最终输出 5-8 张关键帧
步骤 3:压缩
- OpenCV 缩放(长边 > 1024 才缩)+ JPEG 质量 80 保存
MSE 阈值参数:
- 默认
MSE_THRESHOLD = 500(对应像素差异约 22,可调) - 配置项放
config.yaml,便于现场调优
异常兜底:
- ffprobe 失败 → 退化为按 60s 间隔抽帧
- 帧差分析异常 → 退化为等距抽 5 帧
- OpenCV 压缩失败 → 跳过该帧,记录 WARN
7.2 本地 VLM 两阶段调用
7.2.1 视觉分析阶段(多模态输入,并行调用)
本地 VLM 和 Gemini 云端并行调用,两个结果都送入文本融合阶段做交叉验证。
本地 VLM 调用:
- 模型:
llava-phi3(Ollama) - 输入:5-8 张关键帧(Base64 编码)+ 中文 Prompt
- 调用方式:一次性输入所有关键帧(数量已降到 5-8,上下文压力可控)
- 参数:
temperature=0.2, top_p=0.8 - 超时:240s(5-8 张 × 单张 ≤ 8s ≈ 40-64s,有充足余量)
- 输出:VLM_OUTPUT,自然语言描述,按帧顺序逐段输出
Gemini 云端调用(详见 7.6):
- 模型:
gemini-1.5-flash(Google AI Studio API) - 输入:同批 5-8 张关键帧(inline_data Base64)+ 英文 Prompt
- 调用方式:
ThreadPoolExecutor异步并行,与本地 VLM 同时启动 - 参数:
temperature=0.2, top_p=0.8 - 超时:8s(
future.result(timeout=8),超时或异常返回 None,不阻塞主链路) - 熔断保护:连续 5 次失败 → OPEN 状态 15 分钟内跳过调用(详见 7.6.4)
- 输出:GEMINI_OUTPUT,自然语言描述;失败/超时/熔断时为 None
视觉分析 Prompt(中文,含时间戳注入 + 交互式命名):
Oracle 抽帧时记录每帧在视频中的时间偏移(秒),加上视频开始时间(event_start_time),得到绝对时间。注入 Prompt 时按帧序号列出。
NAS 端 Dispatcher 下发任务时,从 family_members 表读取已命名成员清单,注入 Prompt。未命名成员(real_name IS NULL)的特征描述也注入,提示 VLM 用其 abstract_label(如"人物A")标识。
你是家庭监控视频分析助手。请按时间顺序描述下列 {N} 张图片中可见的内容,只描述客观画面,不要猜测或推测。
每张图片对应的时间戳如下:
[图片1] 时间: {frame_timestamp_1} # 例: 2026-08-19 14:03:00
[图片2] 时间: {frame_timestamp_2}
...
[图片N] 时间: {frame_timestamp_N}
每张图片需报告:
1. 人物:数量、衣着(颜色+类型)、可见动作
2. 物品:玩具、奶瓶、家具等显眼物品
3. 互动:人与人、人与物品之间的互动
已知家庭成员清单(按特征匹配,匹配成功用 real_name,未匹配用"人物X"标识):
{known_members_context}
# 注入示例:
# 汤圆: 短发、黄色T恤、男性(real_name=汤圆)
# 人物A: 长发、红色裙子、女性(abstract_label=人物A,未命名)
# 若画面人物与上述特征匹配,使用对应的名字或 abstract_label;
# 若画面人物与上述都不匹配,按出现顺序赋予新标识"人物B"、"人物C"...
输出格式(纯文本,每张图片一段,保留时间戳标记):
[图片1] 时间: {frame_timestamp_1}
内容: ...
[图片2] 时间: {frame_timestamp_2}
内容: ...
...
要求简洁、客观。不要输出 JSON,不要输出 markdown。
7.2.2 文本融合阶段(纯文本输入,多模型平等交叉验证)
- 模型:
llava-phi3(同一模型,纯文本模式) - 输入:所有启用模型的视觉分析输出(键值对,provider → 输出文本)+ 已知成员清单
- 参数:
temperature=0.0, format="json" - 超时:120s
- 输出:JSON 结构(global_summary / entities_json / frame_details / compute_provider)
文本融合 System Prompt(中文,多模型平等交叉验证):
你是一个无情的数据提取器。不要输出任何思考过程,只输出合法 JSON。
输入参数:
- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证):
- <PROVIDER_1> 输出: <OUTPUT_1> # 例: ollama 输出
- <PROVIDER_2> 输出: <OUTPUT_2> # 例: gemini 输出
- ...(数量可变,由实际调用的模型决定)
- 已知成员清单: <KNOWN_MEMBERS>
执行规则:
1. 多个模型的输出平等对待,交叉验证:
- 多个模型一致描述的内容 → 可信度高,必须纳入 frame_details,source_providers 列出所有一致的模型
- 仅单一模型描述的内容 → 纳入 frame_details,source_providers 仅含该模型
- 多个模型冲突时(如人物动作描述不一致)→ 以多数模型一致为准,source_providers 列出多数派模型
2. 画面人物按特征匹配已知成员清单:
- 匹配到已命名成员(real_name 非空)→ person 字段填 real_name(如"汤圆")
- 匹配到未命名成员(real_name 为空)→ person 字段填 abstract_label(如"人物A")
- 都不匹配 → 按出现顺序赋予新标识"人物B"、"人物C"...
3. 提取每张关键帧对应的时间点、人物、动作、衣着,输出到 frame_details 数组。
4. frame_details 每条必须包含 source_providers 数组,列出识别到该条内容的模型来源。
5. compute_provider 字段填入本次实际成功调用的所有模型标识数组(去重)。
6. 仅输出合法 JSON,不输出任何思考过程、markdown 标记或注释。
输出 JSON 结构:
{
"global_summary": "字符串,整个时段的整体摘要,简体中文",
"entities_json": [
{
"person": "字符串,已命名成员的 real_name,或未命名成员的 abstract_label(人物A/B/C...)",
"action": "字符串,简体中文动作描述",
"clothing": "字符串,简体中文衣着描述,可为空字符串"
}
],
"frame_details": [
{
"frame_index": "数字,关键帧序号(1-8)",
"frame_timestamp": "字符串,ISO 8601 格式时间戳,与视觉日志中的时间戳一致",
"person": "字符串,已命名成员的 real_name,或未命名成员的 abstract_label",
"action": "字符串,简体中文动作描述,由 AI 自由生成,无枚举限制",
"clothing": "字符串,简体中文衣着描述,可为空字符串",
"is_attention_event": "布尔值,由 AI 自行判断该动作是否属于需要关注的异常行为(如跌倒、哭闹、玩危险物品等),无需匹配固定清单",
"source_providers": "数组,识别到该条内容的模型标识,如 [\"ollama\",\"gemini\"]"
}
],
"compute_provider": "数组,本次实际成功调用的所有模型标识,如 [\"ollama\",\"gemini\"]"
}
Event-Receiver 对未命名 abstract_label 的处理:
- 收到回调后,对 frame_details 中每个 person 字段值
- 若该值形如
人物X(正则^人物[A-Z]$)且不在family_members表中:- INSERT INTO family_members (abstract_label, feature_description, first_seen_at) VALUES (...)
- feature_description 从该帧的 clothing + action 推断拼接
- 若已存在,跳过(不更新 feature_description,保留首次提取的描述)
frame_details 字段说明:
- 数组长度等于关键帧数量(5-8 条)
frame_timestamp必须与视觉分析阶段 Prompt 注入的时间戳一致frame_index从 1 开始,与图片序号对应action由 AI 自由生成,无枚举过滤is_attention_event由 AI 自行判断,不预设事件清单source_providers必填,数组,列出识别到该条内容的模型标识- 条数无上限,AI 输出多少条就插多少条到
event_details
7.2.3 JSON 解析容错与 Schema 校验
三层容错策略,从最稳到最不稳依次尝试:
def parse_vlm_json(raw: str) -> dict:
# 第 1 层:直接 json.loads
try:
return validate_schema(json.loads(raw.strip()))
except json.JSONDecodeError:
pass
# 第 2 层:提取 markdown fence 内容(llava 经常会加 ```json ... ```)
fence_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', raw, re.DOTALL)
if fence_match:
try:
return validate_schema(json.loads(fence_match.group(1)))
except json.JSONDecodeError:
pass
# 第 3 层:贪婪匹配最大的 {...}
brace_match = re.search(r'\{.*\}', raw, re.DOTALL)
if brace_match:
try:
return validate_schema(json.loads(brace_match.group(0)))
except json.JSONDecodeError:
pass
raise VLMOutputInvalidError(f"无法从 VLM 输出中解析 JSON: {raw[:200]}")
def validate_schema(data: dict) -> dict:
"""Schema 校验 + 脏数据清洗"""
required = ["global_summary", "entities_json", "frame_details", "compute_provider"]
for k in required:
if k not in data:
raise VLMOutputInvalidError(f"缺失字段: {k}")
# entities_json 结构校验
if not isinstance(data["entities_json"], list):
raise VLMOutputInvalidError("entities_json 必须为数组")
cleaned_entities = []
for ent in data["entities_json"]:
if not isinstance(ent, dict):
continue
if "person" not in ent or "action" not in ent:
raise VLMOutputInvalidError("entity 缺少 person 或 action 字段")
cleaned_entities.append({
"person": str(ent["person"]),
"action": str(ent["action"]),
"clothing": str(ent.get("clothing", ""))
})
data["entities_json"] = cleaned_entities
# frame_details 结构校验(无枚举过滤,AI 自由生成)
if "frame_details" not in data:
data["frame_details"] = []
if not isinstance(data["frame_details"], list):
raise VLMOutputInvalidError("frame_details 必须为数组")
cleaned_frames = []
for frame in data["frame_details"]:
if not isinstance(frame, dict):
continue
# 必填字段:frame_index, frame_timestamp, person, action, source_providers
for k in ["frame_index", "frame_timestamp", "person", "action", "source_providers"]:
if k not in frame:
raise VLMOutputInvalidError(f"frame_details 缺少字段: {k}")
# source_providers 必须是非空数组
sp = frame["source_providers"]
if not isinstance(sp, list) or len(sp) == 0:
raise VLMOutputInvalidError("frame_details.source_providers 必须为非空数组")
cleaned_frames.append({
"frame_index": int(frame["frame_index"]),
"frame_timestamp": str(frame["frame_timestamp"]),
"person": str(frame["person"]),
"action": str(frame["action"]), # 由 AI 自由生成,无枚举过滤
"clothing": str(frame.get("clothing", "")),
"is_attention_event": bool(frame.get("is_attention_event", False)),
"source_providers": [str(p) for p in sp] # 模型标识数组,如 ["ollama","gemini"]
})
data["frame_details"] = cleaned_frames
# compute_provider 校验为数组(多模型场景)
if not isinstance(data["compute_provider"], list):
raise VLMOutputInvalidError("compute_provider 必须为数组")
if len(data["compute_provider"]) == 0:
raise VLMOutputInvalidError("compute_provider 不能为空数组")
data["compute_provider"] = [str(p) for p in data["compute_provider"]]
return data
容错层级说明:
- 第 1 层:最理想情况,模型直接输出合法 JSON
- 第 2 层:模型加了 markdown fence 包裹
- 第 3 层:模型输出有前后缀文字,但 JSON 片段完整
- 三层全部失败 → 抛
VLMOutputInvalidError,任务进入 FAILED 状态,由 Dispatcher 退避重试
validate_schema 的脏数据清洗:
- entities_json 中非 dict 元素被过滤
- frame_details 中非 dict 元素被过滤
- frame_details 的
action字段由 AI 自由生成,不做枚举过滤 - frame_details 的
is_attention_event由 AI 自行判断,不预设事件清单 - frame_details 的
source_providers必须为非空数组 - compute_provider 必须为非空数组,由融合阶段根据实际成功调用的模型动态填入
7.3 模型健康探测
接收任务前,AI-Orchestrator 遍历 config.yaml 中所有 enabled: true 的模型,逐个调用其适配器的 health_check() 方法:
| 适配器 | 健康检查方法 | 判定条件 |
|---|---|---|
| OllamaAdapter | GET /api/tags |
HTTP 200 且响应含配置的模型名(如 llava-phi3) |
| GeminiAdapter | GET https://generativelanguage.googleapis.com/v1/models?key=API_KEY |
HTTP 200 且响应含 gemini-1.5-flash |
| OpenAIAdapter (v1.1) | GET /v1/models 带鉴权头 |
HTTP 200 |
| NvidiaAdapter (v1.1) | GET /v1/models 带鉴权头 |
HTTP 200 |
- 至少一个模型健康 → 接收任务,进入 202
- 全部模型不健康 → 返回 503,NAS 端 Dispatcher 退避重试
- 任务执行时,单个模型不健康跳过该模型,其他模型照常调用(动态降级)
- Ollama 由 systemd 托管,FAM-Edge 不管理进程
7.4 任务超时与重试
- 整体超时 600s(Edge 端内部定时器)
- 失败后由 NAS 端 Dispatcher 退避重试:
min(60 * (retry_count + 1) * 2, 600)秒 - 超过 max_retries=3 置为 FAILED
7.5 日志与可观测性(首期最小集)
- 所有日志带
task_id作为 trace_id - 各阶段打 INFO 日志,格式:
[task_id={id}] {stage} done in {ms}ms - 模型调用日志:
[task_id={id}] model {provider} done in {ms}ms, success={bool} - 失败打 ERROR 日志,带 failure_stage 和 error_message
- 不做指标聚合和告警,v1.1 再说
7.6 模型适配器架构(可扩展多模型)
7.6.1 抽象基类
from abc import ABC, abstractmethod
from typing import List, Optional
class BaseModelAdapter(ABC):
"""所有模型适配器的抽象基类。新增模型只需继承此类并实现 4 个方法。"""
def __init__(self, provider_name: str, config: dict):
self.provider_name = provider_name # 如 "ollama", "gemini", "openai"
self.config = config # 从 config.yaml 读到的该模型配置
@abstractmethod
def health_check(self) -> bool:
"""健康检查,返回 True/False"""
pass
@abstractmethod
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[str]:
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,输出自然语言描述。
失败/超时返回 None。"""
pass
@abstractmethod
def get_timeout(self) -> int:
"""该模型的调用超时秒数"""
pass
@abstractmethod
def get_circuit_breaker(self):
"""返回该模型专属的熔断器实例"""
pass
7.6.2 首期实现的适配器
OllamaAdapter(本地模型):
provider_name = "ollama"- 健康检查:
GET http://localhost:11434/api/tags analyze_frames:调用ollama.generate(model="llava-phi3", prompt=..., images=[base64...], options={"temperature":0.2,"top_p":0.8})- 超时:240s
- 熔断器:不启用(本地模型,挂了靠健康检查拦截)
GeminiAdapter(云端):
provider_name = "gemini"- 健康检查:
GET https://generativelanguage.googleapis.com/v1/models?key=API_KEY analyze_frames:调用google-generativeaiSDK,GenerativeModel("gemini-1.5-flash").generate_content(prompt, [PIL.Image, ...])- 超时:8s
- 熔断器:启用,连续 5 次失败 → OPEN 15 分钟,HALF_OPEN 允许一次探测
7.6.3 v1.1 扩展适配器(接口位预留)
- OpenAIAdapter:
provider_name="openai",调openai.ChatCompletion或gpt-4o视觉接口 - NvidiaAdapter:
provider_name="nvidia",调 NVIDIA NIM API - 新增模型只需:继承
BaseModelAdapter+ 在config.yaml的models数组加一项 + 在适配器工厂注册
7.6.4 熔断器(CircuitBreaker,每个云端模型独立实例)
from collections import deque
import time
class CircuitBreaker:
def __init__(self, threshold: int = 5, cooldown: int = 900):
self.failures = deque(maxlen=threshold)
self.state = 'CLOSED' # CLOSED / OPEN / HALF_OPEN
self.cooldown = cooldown
self.last_failure = None
def record_failure(self):
self.failures.append(time.time())
if len(self.failures) >= self.failures.maxlen:
self.state = 'OPEN'
self.last_failure = time.time()
def record_success(self):
self.failures.clear()
self.state = 'CLOSED'
def is_open(self):
if self.state == 'OPEN' and time.time() - self.last_failure > self.cooldown:
self.state = 'HALF_OPEN'
return self.state == 'OPEN'
- 阈值:连续 5 次失败触发 OPEN
- 冷却:15 分钟后转 HALF_OPEN,允许一次探测
- 探测成功 → CLOSED;探测失败 → 重新 OPEN 计时
- OPEN 状态直接跳过该模型调用,
analyze_frames返回 None
7.7 AI-Orchestrator 调用流程(多模型并行)
def process_task_worker(task_data):
# 1. 加载所有启用的模型适配器
adapters = [build_adapter(cfg) for cfg in config["models"] if cfg["enabled"]]
# 2. 健康检查
healthy_adapters = [a for a in adapters if a.health_check()]
if not healthy_adapters:
return 503 # NAS 退避重试
# 3. 抽帧
frame_paths = extract_frames(video_path, task_id)
frame_timestamps = compute_timestamps(video_path, task_data["event_start_time"])
# 4. 并行调用所有健康模型(ThreadPoolExecutor)
from concurrent.futures import ThreadPoolExecutor, as_completed
model_outputs = {}
with ThreadPoolExecutor(max_workers=len(healthy_adapters)) as pool:
futures = {
pool.submit(a.analyze_frames, frame_paths, frame_timestamps, known_members): a.provider_name
for a in healthy_adapters
if not a.get_circuit_breaker().is_open() # 熔断的跳过
}
for fut in as_completed(futures, timeout=240):
provider = futures[fut]
try:
output = fut.result(timeout=a.get_timeout())
if output:
model_outputs[provider] = output
a.get_circuit_breaker().record_success()
else:
a.get_circuit_breaker().record_failure()
except Exception:
a.get_circuit_breaker().record_failure()
# 5. 至少一个模型成功才继续
if not model_outputs:
raise TaskFailedError('vlm_visual', 'All models failed')
# 6. 文本融合(多模型输出平等交叉验证)
fusion_result = run_vlm_fusion(model_outputs, task_data["known_members_context"])
# fusion_result.compute_provider = list(model_outputs.keys())
# fusion_result.frame_details[*].source_providers 由 VLM 在融合时填入
# 7. 回调
send_callback(task_data["webhook_url"], task_id, fusion_result)
关键设计:
- 模型清单由
config.yaml的models数组动态决定,AI-Orchestrator 不硬编码任何模型 - 新增模型只改 config.yaml + 加适配器类,主流程不动
- 熔断器每模型独立,互不影响
model_outputs是 dict,键是 provider_name,值是输出文本,送入融合 Prompt
8. 部署说明
8.0 代码仓库(部署前必读)
- 仓库地址:
http://192.168.50.64:3000/ericwyuan/sentinel-home-ai - 仓库类型:Gitea(自建,仅内网访问)
- 克隆:
git clone http://192.168.50.64:3000/ericwyuan/sentinel-home-ai.git - 详细的 AI Agent 提交规范见 10.1.1 节
8.1 NAS 端
- Python 3.10+,Flask,Gunicorn(1 worker),mysql-connector-python,PyYAML
- 配置 MariaDB,执行 DDL(含
family_members表) - 配置
config/config.yaml(Oracle 地址、token、NAS 端口等;成员不再走 YAML,由family_members表管理) gunicorn -w 1 -b 0.0.0.0:8000 app:app启动
8.2 Oracle 端
- FFmpeg、OpenCV、Ollama
ollama pull llava-phi3- 安装 Python 依赖:
pip install google-generativeai openai(openai 为 v1.1 预留) - 配置
config/config.yaml(含 models 数组,见 8.4) - 配置 Gemini API Key:在
config/config.yaml的models中填入api_key,或通过环境变量GEMINI_API_KEY注入 gunicorn -w 1 -b 0.0.0.0:5000 app:app启动(或 Flask 内置线程池)
8.3 FAM-UI
- Streamlit
- 连接 MariaDB
streamlit run app.py
8.4 config.yaml 结构(多模型可配置)
Oracle 端 config/config.yaml 示例:
# NAS 端回调地址
nas:
webhook_url: "http://100.x.x.10:8000/api/core/callback/event"
media_base_url: "http://100.x.x.10:8000/media"
media_token: "xxx"
# Oracle 端服务
server:
host: "0.0.0.0"
port: 5000
max_concurrent_tasks: 1
# 关键帧筛选参数
video:
candidate_frames: 30 # 粗抽候选帧数
min_key_frames: 5 # 最少关键帧
max_key_frames: 8 # 最多关键帧
mse_threshold: 500 # 帧差阈值
jpeg_quality: 80
max_long_edge: 1024
# 超时(秒)
timeout:
download: 60
vlm_visual: 240 # 单模型视觉分析超时
vlm_fusion: 120
callback: 30
overall: 600
# 模型清单(可扩展,新增模型只需在此数组加一项 + 实现适配器)
models:
- provider: "ollama"
enabled: true
model_name: "llava-phi3"
base_url: "http://localhost:11434"
timeout: 240
circuit_breaker:
enabled: false # 本地模型不启用熔断
threshold: 5
cooldown: 900
- provider: "gemini"
enabled: true
model_name: "gemini-1.5-flash"
api_key: "${GEMINI_API_KEY}" # 从环境变量读取,避免硬编码
timeout: 8
circuit_breaker:
enabled: true
threshold: 5
cooldown: 900
# v1.1 扩展示例(取消注释并填入 API Key 即启用)
# - provider: "openai"
# enabled: false
# model_name: "gpt-4o"
# api_key: "${OPENAI_API_KEY}"
# timeout: 30
# circuit_breaker:
# enabled: true
# threshold: 5
# cooldown: 900
# - provider: "nvidia"
# enabled: false
# model_name: "nvidia/llama-3.1-nemotron-70b-instruct"
# api_key: "${NVIDIA_API_KEY}"
# base_url: "https://integrate.api.nvidia.com/v1"
# timeout: 30
# circuit_breaker:
# enabled: true
# threshold: 5
# cooldown: 900
配置说明:
models是数组,每个元素是一个模型的完整配置provider字段决定使用哪个适配器(OllamaAdapter / GeminiAdapter / ...)enabled: false的模型被跳过,不调用api_key支持${ENV_VAR}语法从环境变量读取,避免硬编码到配置文件- 新增模型只需:实现适配器类 + 在此数组加一项,主流程不动
8.3 FAM-UI
- Streamlit
- 连接 MariaDB
streamlit run app.py
9. 前置风险(必须在阶段一验证)
9.1 llava-phi3 在 Oracle 2C12G 无 GPU 上的可行性
Oracle 免费 2C12G 实例(Ampere A1,ARM)无 GPU,走 CPU 推理。llava-phi3(基于 Phi-2,约 2.7B 参数 + CLIP 视觉编码器)即使 4-bit 量化也需约 2-2.5GB 内存。单张 1024px 图像推理可能数十秒级。
验证方法:
ollama pull llava-phi3- 喂单张 1024px JPEG,问"描述画面",记录耗时
- 喂 5 张、10 张、30 张,记录耗时和是否报错
判定标准(关键帧筛选后 VLM 输入为 5-8 张,与视觉分析 240s 超时对齐):
- 单张 ≤ 8 秒 → 可行,5-8 张预计 40-64s,远低于 240s 超时
- 单张 ≤ 30 秒 → 可接受,5-8 张预计 150-240s,刚好卡超时边界
- 单张 > 30 秒 → 不可行,5-8 张 > 240s,需减帧或换更小模型
- 多张图触上下文截断或报错 → 需调整关键帧数量上限(默认 8 张往下调)
若不可行的备选方向:
- 换更小模型(如 Moondream 或 Qwen-VL 0.5B 级别)
- 减少抽帧数量(30 → 10 → 5)
- 加 swap(不推荐,性能更差)
- 改用 Gemini 作为主链路(与首期"先跑通本地链路"目标冲突,需重新讨论)
10. 验收标准(首期版)
10.1 执行主体说明
- 🤖 AI Agent 自动执行:编码、脚本配置、单元/集成测试、Bug 修复迭代,可连续执行,不受人类工作时间限制
- 👤 需人工介入:账号开通、密钥提供、真实录像、硬件操作、最终效果确认
10.1.1 代码仓库与提交规范(AI Agent 必读)
仓库地址:
http://192.168.50.64:3000/ericwyuan/sentinel-home-ai- 仓库类型:Gitea(自建,仅内网访问)
克隆方式:
git clone http://192.168.50.64:3000/ericwyuan/sentinel-home-ai.git
AI Agent 工作流程(强制):
- 开工前:在仓库根目录执行
git pull --rebase,确保本地与远端同步 - 每完成一项验收标准子任务后(即 10.2 中表格的每一行):
- 🤖 必须执行
git add+git commit+git push - commit message 格式:
[阶段X.Y子任务号] 子任务名称 - 完成内容简述- 例:
[2.1] Task-Scheduler - 实现 60s 轮询 + 持久化 known_files - 例:
[3.3] 文本融合调用 - 三层 JSON 容错 + validate_schema
- 例:
- 一项任务一个 commit,不批量合并多任务到单 commit
- 🤖 必须执行
- 遇到阻塞时(如外网不可达、API 失败):
- 先 commit 当前可工作的部分,commit message 标注
[WIP]前缀 - 例:
[WIP][3.2] 视觉分析调用 - Prompt 注入时间戳,待真实视频测试 - 不要等所有问题解决再提交
- 先 commit 当前可工作的部分,commit message 标注
- 修复 Bug 后:单独 commit,message 格式
fix(模块): 问题简述- 例:
fix(Event-Receiver): frame_details 逐条插入时 abstract_label 未 upsert
- 例:
- 禁止:
- ❌ 不 commit 直接继续下一项任务
- ❌ 一次 commit 包含多个子任务
- ❌ 使用
git push --force(除非明确授权) - ❌ 跳过 hooks(
--no-verify)
commit message 语言:中文为主,技术术语可保留英文
示例(一天的工作流):
git pull --rebase
# 完成 2.1 Task-Scheduler
git add src/fam_core/scheduler.py
git commit -m "[2.1] Task-Scheduler - 60s 轮询 + known_files 持久化到 DB"
git push
# 完成 2.2 Event-Receiver
git add src/fam_core/event_receiver.py src/fam_core/db_layer.py
git commit -m "[2.2] Event-Receiver - frame_details 逐条插入 + abstract_label upsert"
git push
# 发现 Bug 修复
git add src/fam_core/event_receiver.py
git commit -m "fix(Event-Receiver): abstract_label 正则匹配漏了小写字母"
git push
AI Agent 自检清单(每完成一项任务前自问):
- 这项任务对应 10.2 表格的哪一行?验收标准是否达到?
- 是否已 commit + push?
- commit message 是否符合格式?
- 是否在开始下一项前
git pull --rebase?
10.2 详细任务分解与验收标准
阶段一:环境准备与基础设施
| 子任务 | 执行主体 | 验收标准 |
|---|---|---|
| 1.1 Tailscale 组网 | 👤+🤖 | 两端可 ping 通,curl 可访问端口 |
| 1.2 MariaDB 部署 | 🤖 | 建表成功(含 family_members / event_details / chat_history 六张表),字段类型符合 Schema |
| 1.3 Oracle 基础环境 | 👤+🤖 | FFmpeg、Ollama 正常,llava-phi3 模型加载成功 |
| 1.4 模型可行性压测 | 🤖 | 单张 1024px 图推理 ≤ 8 秒(关键帧 5-8 张,与视觉分析 240s 超时对齐,首期阻塞项) |
阶段二:FAM-Core 基础链路(NAS 端)
| 子任务 | 执行主体 | 验收标准 |
|---|---|---|
| 2.1 Task-Scheduler | 🤖 | 新视频 60s 内被扫描,无重复插入;重启后 known_files 不丢失(持久化到 DB 或文件) |
| 2.2 Event-Receiver | 🤖 | 回调返回 200;monitor_events 插 1 条聚合记录;frame_details 数组逐条插入 event_details(条数无上限);未命名 abstract_label 自动 upsert 到 family_members |
| 2.3 状态机流转 | 🤖 | 失败自动重试,超 3 次置为 FAILED;failure_stage 字段正确记录失败阶段 |
| 2.4 任务下发客户端 | 🤖 | 收到 202 后状态切换为 PROCESSING;payload 注入 family_members 表的已命名+未命名成员清单 |
| 2.5 Member-Manager | 🤖 | GET /api/member/unnamed 返回未命名人物列表;POST /api/member/name 命名后批量 UPDATE event_details 回溯历史,返回更新条数 |
阶段三:FAM-Edge 本地推理链路(Oracle 端)
| 子任务 | 执行主体 | 验收标准 |
|---|---|---|
| 3.1 Video-Preprocessor | 🤖 | 等距粗抽 30 张候选帧;OpenCV MSE 帧差分析筛 5-8 张关键帧(首末帧必选);长边 ≤ 1024px,JPEG 质量 80 |
| 3.2 视觉分析调用 | 🤖 | 10 次测试无空输出、无崩溃;Prompt 注入每帧时间戳(Oracle 计算 = 视频开始时间 + 帧偏移);输出按帧顺序逐段 |
| 3.3 文本融合调用 | 🤖 | 20 次测试 JSON 解析成功率 ≥ 90%(三层容错:直接解析 → markdown fence → 贪婪 brace 匹配);validate_schema 通过;action 由 AI 自由生成,无枚举过滤;is_attention_event 由 AI 自行判断 |
| 3.4 成员标识提取 | 🤖 | 已命名成员用 real_name;未命名成员用 abstract_label(人物A/B/C...);新人物按出现顺序赋予新标识;特征描述落 family_members.feature_description |
| 3.5 AI-Orchestrator 串联 | 🤖 | 端到端全自动完成(下载 → 抽帧 → VLM 视觉 → VLM 融合 → 回调 → 清理);各阶段耗时打 INFO 日志(带 task_id) |
阶段四:AI 对话与成员命名(NAS 端)
| 子任务 | 执行主体 | 验收标准 |
|---|---|---|
| 4.1 Chat-Handler | 🤖 | POST /api/chat/ask 接收问题;查 event_details 拼上下文;调 Oracle Ollama 纯文本返回自然语言回答;写 chat_history;明细 > 50 条时按小时聚合 |
| 4.2 成员命名流程 | 👤+🤖 | FAM-UI 展示未命名人物列表 + 特征描述;用户输入真名 → 批量回溯更新 event_details;命名后后续视频分析直接用 real_name |
| 4.3 对话准确率验证 | 👤+🤖 | 问"汤圆今天干嘛了",回答包含主要活动时段 + 关注事件提示;当天无数据时明确说"今天没有观察到汤圆" |
阶段五:FAM-UI + 联调 + 验收
| 子任务 | 执行主体 | 验收标准 |
|---|---|---|
| 5.1 Streamlit 基础页面 | 🤖 | 事件列表分页加载、按日期筛选可用 |
| 5.2 成员命名页 | 🤖 | 展示未命名人物 + 命名输入框;命名后刷新已命名列表 |
| 5.3 AI 对话页 | 🤖 | 输入框 + 调 /api/chat/ask + 展示回答;按 queried_person 预设快捷提问按钮 |
| 5.4 compute_provider 可视化 | 🤖 | 图表与数据库统计一致(首期阶段全为 local_only) |
| 5.5 真实素材端到端联调 | 👤+🤖 | 至少 3 个真实切片跑通,VLM 输出 frame_details 落库正确,AI 对话能回答当日活动,结果经人工确认 |
阶段四(原 v1.0 的 Gemini 增强)和阶段五的长稳测试(24-48h)推到 v1.1+。
文档结束