From 1fef01d97944d5b598dc4da0f7ece19d1ad46e5e Mon Sep 17 00:00:00 2001 From: liyizhouAI Date: Wed, 15 Apr 2026 09:37:22 +0800 Subject: [PATCH] feat: v0.3 - Manus replay + token tracking + SIGTERM fix + accelerate button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the v0.3 milestone commit before the v0.4 big version push. Major themes: process replay, runtime stability, cost observability. ## New Features - **Manus-style process replay** (frontend + backend) - `GET /api/simulation//replay` returns full workflow + agents + rounds + aggregate - `frontend/src/views/SimulationReplayView.vue` 3-column layout (workflow / actions / stats) - bottom scrubber with play/pause/step + 5 speed levels (0.5x-10x) - filters out stale actions from previous runs via latest simulation_start timestamp - **Token usage tracking** (`backend/app/utils/token_tracker.py`) - process-wide stage→model→tokens counter - LLMClient auto-records prompt/completion tokens after each call - stages tagged at API entry: step1_ontology, step2_graph_build, step3_prepare, step5_report - `GET /api/usage/summary` for live stats + CNY cost estimate - `GET /api/usage/estimate-simulation` for OASIS subprocess estimation - pricing table for GLM/SiliconFlow/MiniMax/OpenAI/Anthropic models - documented as internal-use, removed from customer-facing builds - **Step 2 "Skip & Continue" button** - lets user stop profile generation early and proceed with what's already generated - `simulation_manager.request_accelerate()` + cancel_check in oasis_profile_generator - new endpoint `POST /api/simulation/prepare/accelerate` ## Critical Bug Fix - **SIGTERM no longer kills running simulation subprocess** - root cause: `SimulationRunner.register_cleanup()` registered SIGTERM/SIGINT/SIGHUP handlers that called `os.killpg` on every tracked sim child, even though spawn already used `start_new_session=True` to give children isolated sessions - fix: neutered `register_cleanup` to a no-op; `cleanup_all_simulations` itself preserved for explicit stop_simulation paths - validated: killed Flask backend twice, simulation subprocess kept running - impact: hot-reload backend code without interrupting in-flight simulations ## Performance & Tuning - semaphore 30 → 100 (twitter + reddit) for higher LLM concurrency - discovered 200 agents as memory/cost/statistical sweet spot for 8G server (503 agents OOMs both platforms; 200 agents fits cleanly with 95% confidence margin) ## Documentation - **PRD.md** rewritten as v0.3 baseline (10 chapters + 2 appendices, 639 lines) - product positioning across 3 usage modes (one-shot / model-reuse / SaaS) - v0.4 roadmap: domestic platforms (douyin/wechat/xiaohongshu/weibo), fork sim, multi-tenant - operational lessons: HF mirror, Tencent PyPI mirror, GLM-4-Flash choice, agent count - decision log with dates - per-stage token/cost breakdown for typical 200-agent run - **README.md / README-ZH.md** updated with replay step + Graphiti+Neo4j ## Files Touched 19 files changed, 1105 insertions(+), 246 deletions(-) --- PRD.md | 641 ++++++++++--- README-ZH.md | 11 +- README.md | 7 +- backend/app/__init__.py | 3 +- backend/app/api/__init__.py | 2 + backend/app/api/graph.py | 12 +- backend/app/api/report.py | 6 +- backend/app/api/simulation.py | 386 +++++++- backend/app/api/usage.py | 118 +++ .../app/services/oasis_profile_generator.py | 35 +- backend/app/services/simulation_manager.py | 44 +- backend/app/services/simulation_runner.py | 82 +- backend/app/utils/llm_client.py | 15 +- backend/app/utils/token_tracker.py | 238 +++++ backend/scripts/run_parallel_simulation.py | 4 +- frontend/src/api/simulation.js | 17 + frontend/src/components/Step2EnvSetup.vue | 66 +- frontend/src/router/index.js | 7 + frontend/src/views/SimulationReplayView.vue | 901 ++++++++++++++++++ locales/en.json | 6 + locales/zh.json | 6 + 21 files changed, 2361 insertions(+), 246 deletions(-) create mode 100644 backend/app/api/usage.py create mode 100644 backend/app/utils/token_tracker.py create mode 100644 frontend/src/views/SimulationReplayView.vue diff --git a/PRD.md b/PRD.md index 834431c0..e960eeab 100644 --- a/PRD.md +++ b/PRD.md @@ -1,243 +1,580 @@ # Foresight 先见之明 — 产品需求文档 (PRD) -## 1. 产品概述 - -Foresight(先见之明)是一个基于知识图谱和 LLM 的社交媒体舆情模拟平台。用户上传文档资料,系统自动构建知识图谱、生成虚拟 Agent 画像,并模拟社交媒体上的传播与互动行为,最终生成分析报告。 - -**核心价值**:在事件发生前预判舆论走向,帮助品牌、政府、机构提前制定应对策略。 - -**上游项目**:基于 [MiroFish](https://github.com/666ghj/MiroFish) v0.1.2 二次开发。 +> **版本**:v0.3 — 2026-04-15 大版本前最后一次校准 +> **基线**:基于 [MiroFish](https://github.com/666ghj/MiroFish) v0.1.2 二次开发,已大量重构。 --- -## 2. 目标用户 +## 1. 产品定位 -| 用户类型 | 使用场景 | -|----------|----------| -| 品牌公关团队 | 新品发布前预判舆论反应 | -| 政府舆情分析师 | 政策出台前模拟民意 | -| 内容创作者 | 预测爆款视频的传播路径 | -| 研究人员 | 社交网络传播行为研究 | +**Foresight 是一个把"需要预测的未知"变成"可演化的数字沙盘"的群体智能引擎。** + +用户上传任何一份"信号"——一段视频文案、一份政策草案、一次金融事件复盘、一段小说背景——Foresight 自动构建该信号所属世界的知识图谱,孵化出几百个有完整人格、记忆、行为模式的虚拟 Agent,让他们在虚拟社交平台上自由互动、扩散、对抗、共鸣,最后输出一份"如果这件事真的发生,世界会变成什么样"的详尽预测报告。 + +**核心命题**:让"未来"在数字沙盘里先演练一遍,让决策在百战模拟之后才下刀。 + +### 三种使用形态 + +| 形态 | 用户 | 典型问题 | 输出 | +|---|---|---|---| +| **单次预测** | 个人 / 临时项目 | "这条视频发出去会火吗?" | 一份报告 + 可回放的传播沙盘 | +| **建模 + 复用** | 团队长期使用 | "我有一批 200 人的目标受众样本,每周给我跑 5 条新内容看哪个最容易出圈" | 同一批 agent 反复跑不同 initial_posts | +| **多租户 SaaS**(v0.4 路线图) | 客户分账户 | "金融预测 / 舆情扩散 / 关系发展 各建一套独立沙盘" | 子账户体系 + 按次计费 + 项目隔离 | --- -## 3. 系统架构 +## 2. 产品愿景:从工具到平台 + +### 2.1 v0.3 现状(已实现) + +一个**单租户**的端到端预测流水线:上传文档 → 图谱 → 人设 → 配置 → 模拟 → 报告 → 回放 → 互动。 + +### 2.2 v0.4 目标(下一个大版本) + +**Foresight 升级为多领域可定制的预测系统。** 不再只服务"舆情扩散"一个场景,而是抽象为**"任何可被多 agent 互动建模的预测问题"**: + +| 应用领域 | 输入信号 | Agent 类型 | 预测输出 | +|---|---|---|---| +| **内容传播预测** | 视频逐字稿 / 帖子文案 | 200 个画像各异的目标受众 | 触达率 / 互动率 / 传播路径 / 爆款概率 | +| **舆情扩散预测** | 突发事件 / 政策草案 | 不同立场的意见领袖 + 普通群众 | 舆论走向 / 关键拐点 / 情绪曲线 | +| **金融市场预测** | 财报 / 政策 / 黑天鹅事件 | 散户 / 机构 / 量化 / 媒体 | 价格反应 / 资金流向 / 板块联动 | +| **人际关系发展** | 角色背景 / 起始事件 | 故事中的所有角色 | 关系演化树 / 关键转折 / 多结局 | +| **品牌危机推演** | 危机事件 + 公关方案 | 用户 / 媒体 / 监管 / 竞品 | 不同应对策略下的舆情走向 | + +**核心抽象**:每个领域 = 一组(领域语料 + 领域 agent 库 + 领域平台规则 + 领域评估指标)。Foresight 提供**通用的工作流编排**,领域知识用配置即可注入。 + +### 2.3 v0.5 商业化(远期) + +- 子账户体系(org / user / project 三级权限) +- 按模拟次数 / agent 数 / 报告深度计费 +- 模型 fork & 对比模式(A/B 内容对比传播效率) +- 数据隔离 + 合规审计 + +--- + +## 3. 系统架构(v0.3 实际部署) ``` + ┌─────────────────────┐ + │ M-flow(记忆系统) │ + │ 踩坑/经验/凭证记录 │ + └─────────────────────┘ 用户浏览器 │ - ├── 前端 (Vue 3 + Vite) - │ 部署: 腾讯云 COS + CDN - │ 域名: foresight.yizhou.chat + ├── 前端 (Vue 3 + Vite) + │ 部署:腾讯云 COS + CDN + │ 域名:foresight.yizhou.chat + │ 新增页面:/simulation/:id/replay(Manus 式过程回放) │ - └── 后端 (Flask + Python) - 端口: 5001 + └── HTTPS → api.foresight.yizhou.chat (Nginx) │ - ├── LLM API (MiniMax M2.7 Highspeed) - │ 用途: 本体生成、画像生成、配置生成、报告生成 - │ - └── Zep Cloud API - 用途: 知识图谱存储、搜索、记忆更新 + └── Backend Flask (5001) 服务器:腾讯云 2C8G + │ OS:Ubuntu 24.04 + │ Python:3.11.15 (uv 管理) + │ venv:/opt/foresight/backend/.venv-311 + │ + ├── LLM API:智谱 GLM-4-Flash (之前用 MiniMax M2.7,已弃) + │ 用途:本体、画像、配置、报告、模拟决策 + │ Endpoint:https://open.bigmodel.cn/api/paas/v4/ + │ + ├── Knowledge Graph:Graphiti + Neo4j (之前用 Zep Cloud,已弃) + │ 部署:Docker 容器 neo4j:5.26-community + │ Embedding:BAAI/bge-m3 via SiliconFlow + │ Graphiti LLM:Qwen2.5-32B via SiliconFlow + │ + ├── HF Hub Mirror:hf-mirror.com OASIS 推荐模型 twhin-bert-base + │ + └── OASIS 模拟引擎 fork 自 camel-oasis 0.2.5 + 支持 Twitter / Reddit 双平台并行 + 🚧 国内平台抽象层:v0.4 路线图(抖音/视频号/小红书/微博) ``` +### 3.1 关键基础设施决策(v0.3 沉淀的经验) + +| 决策点 | 当前选择 | 弃用方案 | 原因 | +|---|---|---|---| +| LLM | 智谱 GLM-4-Flash | MiniMax M2.7 / GPT-4o-mini | Flash 单次延迟 0.5-1s,是 OASIS 高吞吐场景的最优解 | +| 知识图谱 | Graphiti + 自托管 Neo4j | Zep Cloud | 摆脱外部依赖、可控、免月费 | +| Python | 3.11(uv 管理) | 系统 3.12 | camel-oasis<3.12 不兼容 3.12 | +| 包源 | 腾讯云 PyPI 镜像 | 直连 PyPI | 国内服务器直连慢 100x | +| HF 模型源 | hf-mirror.com | huggingface.co | 国内服务器连不上 hf 主站 | +| Agent 数甜点 | **200 agents** | 503(默认) | 8G 内存上限 + 95% 置信区间足够 | +| 运行参数 | semaphore=100 / 双平台 | semaphore=30 / 单平台 | 8G 升级后可承载 | + --- -## 4. 核心功能流程(5 步流水线) +## 4. 核心功能流水线(5 步 + 1 回放) -### Step 1: 图谱构建 +### Step 1: 知识图谱构建 -**输入**: 用户上传文档(PDF/MD/TXT)+ 模拟需求描述 +**输入**:上传文档(PDF/MD/TXT/DOCX)+ 模拟需求自然语言描述 -**流程**: +**流程**: 1. 文档解析 → 文本提取 -2. LLM 分析文档 → 生成本体(10 个实体类型 + 6-10 个关系类型) -3. 文本分块 → 批量导入 Zep → 构建知识图谱 +2. LLM 分析全文 → 生成本体(10 个实体类型 + 6-10 个关系类型) +3. 文本分块 → 批量调用 Graphiti → 写入 Neo4j 4. 返回图谱可视化(节点 + 边) -**API 端点**: -- `POST /api/graph/ontology/generate` — 本体生成 -- `POST /api/graph/build` — 图谱构建 -- `GET /api/graph/task/` — 构建进度查询 +**API**: +- `POST /api/graph/ontology/generate` +- `POST /api/graph/build` +- `GET /api/graph/task/` -**Token 消耗**: -| 服务 | 小文档 (10 实体) | 大文档 (50 实体) | -|------|------------------|------------------| -| LLM (本体生成) | 3K-8K | 5K-12K | -| Zep (图谱构建) | 5K-10K | 20K-50K | +### Step 2: Agent 人设生成 -### Step 2: 环境配置 +**输入**:已构建的知识图谱 -**输入**: 已构建的知识图谱 +**流程**: +1. 从 Neo4j 读取图谱实体与关系 +2. 按实体类型筛选,调用 LLM 为每个实体生成 OASIS Agent Profile +3. 每个 profile 含:人设故事 / MBTI / 年龄 / 职业 / 兴趣话题 / 活跃时段 / 互动倾向 +4. 实时写入 `reddit_profiles.json` 和 `twitter_profiles.csv` -**流程**: -1. 从 Zep 读取图谱实体和关系 -2. 按实体类型筛选,为每个实体生成 Agent 画像(LLM) -3. 生成模拟配置:时间线、事件、Agent 活动参数、平台配置 +**新功能**(v0.3 新增): +- **加速完成按钮**:右上角"加速完成",用户可在生成到任意数量时立即停止剩余生成,使用已生成的 profile 进入下一步 -**API 端点**: -- `POST /api/simulation/prepare` — 准备模拟环境 +**API**:`POST /api/simulation/prepare` -**Token 消耗**: -| 服务 | 小规模 (10 实体) | 大规模 (50 实体) | -|------|------------------|------------------| -| LLM (画像生成) | 20K-40K | 50K-100K | -| LLM (配置生成) | 20K-35K | 40K-50K | -| Zep (实体读取) | 1K-2K | 5K-10K | +### Step 3: 模拟配置生成 -### Step 3: 模拟运行 +**输入**:profiles + 模拟需求 + 文档原文 -**输入**: Agent 画像 + 模拟配置 +**流程**: +1. LLM 智能生成时间配置(peak hours / off-peak hours / 活跃度系数) +2. LLM 智能生成事件配置(initial_posts 列表 + 轮次事件) +3. LLM 为每个 agent 分配活跃时段、互动概率 +4. 输出 `simulation_config.json` -**流程**: -1. 创建虚拟社交平台环境 -2. Agent 按配置执行社交行为(发帖、评论、转发、点赞等) -3. 实时记录互动日志 -4. 可选:将 Agent 行为写回 Zep 图谱(记忆更新) +### Step 4: 双平台模拟运行 -**API 端点**: -- `POST /api/simulation/run` — 启动模拟 -- `GET /api/simulation/status/` — 查询状态 -- `GET /api/simulation/history` — 历史记录 +**输入**:profiles + simulation_config -**Token 消耗**: -| 服务 | 说明 | -|------|------| -| Zep (记忆更新,可选) | 每个 Agent 动作 100-300 tokens,大规模模拟可达 1M+ | +**流程**: +1. 启动 OASIS Twitter env + Reddit env 并行(asyncio.gather) +2. 每轮按时间窗口激活若干 agent +3. 每个激活的 agent 调用 LLM 生成行为(发帖 / 评论 / 点赞 / 转发 / 关注) +4. 实时写入 `twitter/actions.jsonl` 和 `reddit/actions.jsonl` +5. 每平台限制 semaphore=100 防止 API 过载 -### Step 4: 报告生成 +**性能指标**(200 agents 双平台 8G 服务器): +- 启动 + tokenizer 加载:~30-60s +- 每轮:~30-60s(取决于活跃 agent 数) +- 15 rounds 完整跑完:~10-15 分钟 -**输入**: 模拟结果 + 知识图谱 +**API**: +- `POST /api/simulation/start` — 启动 +- `POST /api/simulation/stop` — 停止 +- `GET /api/simulation//run-status/detail` — 实时状态 -**流程**: +**新功能**(v0.3 新增): +- **后端 SIGTERM 误杀子进程 bug 修复**:之前重启 Flask 会连带杀掉正在跑的模拟,已通过让 `register_cleanup` 变为 no-op 修复,现在可以热更新后端代码不影响运行中的模拟。 + +### Step 5: 报告生成 + +**输入**:完整模拟结果 + 知识图谱 + +**流程**: 1. LLM 规划报告大纲(5 个章节) -2. 每个章节使用 ReACT 循环(推理→工具调用→生成) -3. 工具调用包括:图谱搜索(InsightForge/Panorama)、节点详情查询 -4. 最终输出结构化分析报告 +2. 每章节 ReACT 循环(推理 → 工具调用 → 生成) +3. 工具:图谱搜索(InsightForge / Panorama)/ 节点详情 / agent 行为统计 +4. 输出结构化 Markdown 报告 -**API 端点**: -- `POST /api/report/generate` — 生成报告 +**API**:`POST /api/report/generate` -**Token 消耗**: -| 服务 | 小规模 | 大规模 | -|------|--------|--------| -| LLM (ReACT 多轮) | 50K-80K | 100K-150K | -| Zep (图谱搜索) | 5K-10K | 10K-20K | +> Token 消耗最大的环节,单次完整报告约 80-150K tokens。 -> 报告生成是整个流水线中**Token 消耗最大**的环节。 +### Step 6(新增): Manus 式过程回放 -### Step 5: 交互问答 +**v0.3 新增的核心功能**。把整个 Foresight 工作流(Step 1-5)做成可拖拽 / 可播放的可视化时间线,方便给客户、合作方、自己复盘演示。 -**输入**: 用户问题 +**界面布局**: -**流程**: -1. 用户自由提问 -2. 系统结合图谱搜索 + LLM 回答 +``` +┌─────────────────────────────────────────────────────┐ +│ ← 返回 foresight 回放 sim_xxxx [running] │ +├─────────┬──────────────────────────────┬────────────┤ +│ 工作流 │ 当前动作(大卡片) │ 全局统计 │ +│ 时间线 │ ┌─────────────────────┐ │ │ +│ │ │ 👤 90后 (#6) │ │ 总动作 N │ +│ ● 步骤1 │ │ 📱 reddit │ │ rounds 8 │ +│ │ 文档 │ │ ✏️ CREATE_POST │ │ │ +│ │ │ │ "芒格说复利..." │ │ 类型分布 │ +│ ● 步骤2 │ └─────────────────────┘ │ POST 85%│ +│ │ 图谱 │ │ LIKE 12%│ +│ │ │ 动作流(滚动 30 条) │ │ +│ ● 步骤3 │ ┌─────────────────────┐ │ Top Agents │ +│ │ 人设 │ │ r03 90后 ✏️ ... │ │ 90后 8 │ +│ │ │ │ r03 70后 💬 ... │ │ 00后 6 │ +│ ● 步骤4 │ │ r04 AI 📤 ... │ │ │ +│ │ 配置 │ └─────────────────────┘ │ 平台分布 │ +│ │ │ │ TW 17 RD 8 │ +│ ● 步骤5 │ │ │ +│ 模拟 │ │ │ +├─────────┴──────────────────────────────┴────────────┤ +│ ⏮ ▶ ⏭ ━━━━━●───── Round 8/15 Day 1 08:00 │ +│ 速度 0.5x · 1x · 2x · 5x · 10x │ +└─────────────────────────────────────────────────────┘ +``` -**API 端点**: -- `POST /api/report/chat` — 实时问答 +**核心能力**: +- 左栏:5 步工作流时间线(带状态 + 元数据) +- 中上:当前动作大卡片(agent 头像 + 内容 + 平台/类型 tag) +- 中下:动作流滚动(最近 30 条,可点击跳转) +- 右栏:聚合统计(总数 / 类型分布 bar / Top 8 agents / 平台分布卡) +- 底部:scrubber 时间轴 + 播放控件 + 5 档速度 -**Token 消耗**: 每条消息 2K-4K tokens (LLM + Zep) +**数据源**:`GET /api/simulation/:id/replay` 一次性返回所有数据,前端无需多次请求。 + +**最新 run 过滤**:actions.jsonl 是 append-only 文件,多次 run 会累积。后端通过扫描最近一次 `simulation_start` 事件的时间戳,过滤掉历史 run 残留 actions,保证回放只显示最新一次。 --- -## 5. Token 消耗总览 +## 4.5 Token 用量追踪与成本估算(v0.3 新增 · 内部用) -### 单次完整流水线估算 +为了支持后续定价决策和成本控制,v0.3 新增 token 追踪模块。**销售给客户的版本需要移除此 blueprint 注册**(去掉 `app/__init__.py` 里的 `usage_bp` 即可)。 -| 阶段 | 主要 API | 10 实体 | 50 实体 | -|------|----------|---------|---------| -| 图谱构建 | LLM + Zep | 8K-18K | 25K-62K | -| 环境配置 | LLM + Zep | 41K-77K | 95K-160K | -| 模拟运行 | Zep (可选) | 0-100K | 0-1M+ | -| 报告生成 | LLM + Zep | 55K-90K | 110K-170K | -| **合计 (不含模拟记忆)** | | **~100K-185K** | **~230K-392K** | +### 工作机制 -### API 费用构成 +- `backend/app/utils/token_tracker.py` 提供进程内全局 stage→model→tokens 计数器 +- `LLMClient` 每次 `chat()` 自动 record 一次 `usage.prompt_tokens / completion_tokens` +- 各 API 端点在入口处 `token_tracker.set_stage("step1_ontology")` 等 +- 价格表内置在 `PRICING` 字典,覆盖 GLM / SiliconFlow / MiniMax / OpenAI / Anthropic 主流模型 -| 外部服务 | 用途 | 计费方式 | -|----------|------|----------| -| **MiniMax M2.7 Highspeed** | 所有 LLM 推理(本体/画像/配置/报告/问答) | 按 token 计费 | -| **Zep Cloud** | 知识图谱(存储/搜索/记忆更新) | 按 API 调用计费 | +### Stage 命名 + +| Stage | 触发位置 | 说明 | +|---|---|---| +| `step1_ontology` | `POST /api/graph/ontology/generate` | 文档分析与本体生成 | +| `step2_graph_build` | `POST /api/graph/build` | Graphiti 图谱构建(含 LLM 提取实体) | +| `step3_prepare` | `POST /api/simulation/prepare` | Profile 生成 + 模拟配置生成 | +| `step4_simulation` | OASIS 子进程(**不在 Flask 内**) | 需要走 `estimate-simulation` API 估算 | +| `step5_report` | `POST /api/report/generate` | 报告 ReACT 多轮调用 | + +### API + +| 端点 | 用途 | +|---|---| +| `GET /api/usage/summary` | 当前累计统计:每个 stage 的 prompt/completion tokens + 估算成本(CNY) | +| `POST /api/usage/reset` | 清空(可指定 stage) | +| `GET /api/usage/estimate-simulation?rounds=15&active_agents_per_round=10` | 估算 OASIS 模拟成本(无法精确测) | +| `POST /api/usage/set-stage` | 手动切 stage(测试用) | + +### 局限 + +1. **OASIS 模拟子进程的 LLM 调用无法被 Flask 进程的 tracker 捕获**(camel-ai 用自己的 client)。需要走 estimate API 用经验公式估算。 +2. **进程重启会丢失数据**。如需持久化,加 `reset()` 前 dump 到 JSON 文件即可。 +3. **价格表是 2026-04 价格快照**,需要定期更新 `PRICING` 字典。 + +### 典型 Foresight 单次完整流水线成本估算(200 agents / 15 rounds / GLM-4-Flash) + +| Stage | Tokens 范围 | CNY 估算 | +|---|---|---| +| Step 1 本体生成 | 5K-15K | 0.001-0.003 | +| Step 2 图谱构建 | 50K-200K | 0.005-0.020 | +| Step 3 Profile + Config | 100K-300K | 0.010-0.030 | +| Step 4 模拟 (15 rounds) | 1M-3M | 0.10-0.30 | +| Step 5 报告生成 | 80K-150K | 0.008-0.015 | +| **合计** | **~1.2M-3.7M** | **~0.12-0.37 元** | + +> 关键洞察:**模拟运行 (Step 4) 占总成本的 80%+**,但因为 GLM-4-Flash 单价极低,单次完整流水线 < 0.5 元 RMB。这给定价留了巨大空间:以成本 0.5 元 / 次,售价 5-50 元 / 次给客户都是合理的(取决于客户类型与定制化程度)。 + +--- + +## 5. v0.4 路线图(下一个大版本要做的事) + +按优先级: + +### P0:国内平台抽象层 + +**痛点**:当前 Twitter + Reddit 是欧美社交语境,国内 IP / 内容 / 客户都不匹配。 + +**目标**:支持抖音 / 视频号 / 小红书 / 微博 / 公众号 5 个国内平台。 + +**两条路径**: + +| 路径 A(快速 MVP,2-3 周) | 路径 B(真模拟,1-2 月) | +|---|---| +| 基于 OASIS Reddit 模式 fork 一份"通用国内平台"虚拟实现 | 抛弃 OASIS,自研 platform engine | +| 不真正模拟抖音 ML 算法,用参数化传播模型 | 真模拟抖音 FYP / 视频号双引擎 / 小红书 tag 聚类 | +| LLM 决定 agent 互动 + 配置文件定义平台规则参数 | 行业报告训练参数 + 黑盒推荐算法逼近 | +| 可申请客户付费试点 | 可申请专利 / 学术发表 | + +**先走路径 A**,3 周内可演示。客户付费数据反哺路径 B。 + +**配置形态(设计稿)**: + +```json +{ + "platforms": [ + { + "id": "douyin", + "type": "short_video", + "weight": 0.45, + "rules": { + "recommendation": "fyp_engagement_loop", + "viral_threshold": 0.08, + "interaction_types": ["like", "comment", "share", "follow", "watch_full"], + "key_features": ["video_completion_rate", "comment_density", "share_velocity"] + } + }, + { "id": "xiaohongshu", "type": "lifestyle_feed", "weight": 0.25, "rules": {...} }, + { "id": "wechat_video", "type": "social_graph_video", "weight": 0.15, "rules": {...} }, + { "id": "weibo", "type": "broadcast_micro_blog", "weight": 0.10, "rules": {...} }, + { "id": "wechat_official", "type": "subscription_long_form", "weight": 0.05, "rules": {...} } + ] +} +``` + +### P1:模型复用 / Fork 模拟 + +**痛点**:现在每次跑模拟都要走完 Step 1-4,重复劳动。 + +**目标**:基于已建好的 simulation 一键 fork 一个新版本,只换 `event_config.initial_posts` 即可。 + +**功能点**: +- "Fork as Template" 按钮 +- 模板库:保存通过验证的 sim 作为预设 +- A/B 对比模式:两条新内容并排跑,结果并排展示 + +### P2:多租户 SaaS 改造 + +**功能模块**(需要派出 8 个并行 sub-agent 协同开发): + +| Sub-Agent | 模块 | 任务 | +|---|---|---| +| Architect | 多租户架构 | PostgreSQL schema 隔离 / API gateway / RBAC 设计 | +| Backend Engineer | API 改造 | 加 user_id / org_id / billing 字段,所有数据按租户隔离 | +| Frontend Engineer | UI 改造 | 登录 / 注册 / dashboard / 子账户管理界面 | +| Platform Engine | 平台抽象 | 上面 P0 的国内平台抽象层落地 | +| Auth & Security | 认证 | 接 Better Auth / OAuth / API key 发放 | +| Billing | 计费 | 接微信 / 支付宝 / Stripe,按模拟次数 / agent 数计费 | +| DevOps | 容器化 | Docker + k8s + 监控 + 扩容策略 | +| PM/Critic | 全程 review | 商业目标对齐 + 架构 review | + +### P3:稳定性 / 运维改进 + +- ✅ Backend SIGTERM 误杀子进程 bug(v0.3 已修) +- ⏳ 子进程心跳超时:当前 simulation 子进程挂掉后 state 不会自动更新成 failed,需要加心跳检测 +- ⏳ 模拟运行内存预算检查:启动前根据 agent 数 + 平台数预估内存,超出可用内存时拒绝启动 +- ⏳ Replay actions.jsonl 自动清理:每次新 run 启动前清空旧文件,避免历史残留 +- ⏳ 多模拟并发支持:单服务器同时跑 2-3 个 sim(需要更大内存或更精细资源调度) --- ## 6. 技术栈 ### 前端 + | 技术 | 版本 | 用途 | -|------|------|------| -| Vue 3 | 3.x | UI 框架 | +|---|---|---| +| Vue 3 | 3.5+ | UI 框架(Composition API + script setup) | | Vite | 7.x | 构建工具 | +| Vue Router 4 | - | 路由 | +| vue-i18n | 9.x | 中英双语 | | D3.js / Force Graph | - | 图谱可视化 | +| 字体 | Space Grotesk + Noto Sans SC + JetBrains Mono | 设计语言 | ### 后端 + | 技术 | 版本 | 用途 | -|------|------|------| -| Python | 3.x | 运行时 | -| Flask | - | Web 框架 | -| OpenAI SDK | - | LLM 客户端(兼容 MiniMax) | -| zep-cloud | 3.13.0 | Zep 知识图谱 SDK | +|---|---|---| +| Python | 3.11.15 (uv 管理) | 运行时 | +| Flask | 3.1.3 | Web 框架 | +| OpenAI SDK | - | LLM 客户端(兼容智谱/MiniMax/SiliconFlow) | +| camel-ai | 0.2.78 | OASIS 依赖 | +| camel-oasis | 0.2.5 | 社交模拟引擎 | +| graphiti-core | - | Neo4j 图谱构建框架 | +| transformers + torch | - | OASIS 内置 twhin-bert-base | +| neo4j-driver | - | Neo4j 客户端 | ### 部署 + | 组件 | 平台 | 说明 | -|------|------|------| -| 前端静态文件 | 腾讯云 COS + CDN | foresight.yizhou.chat | -| SSL 证书 | Let's Encrypt | 通过 acme.sh 签发 | -| 后端 API | 待部署 | 需要云服务器运行 Flask | +|---|---|---| +| 前端静态 | 腾讯云 COS + CDN | foresight.yizhou.chat | +| SSL 证书 | acme.sh / Let's Encrypt | 自动续期 | +| 后端 | 腾讯云轻量 2C8G Ubuntu 24.04 | api.foresight.yizhou.chat → 127.0.0.1:5001 | +| Neo4j | Docker container neo4j:5.26-community | bolt://localhost:7687 | +| 进程管理 | nohup + disown(无 systemd / 无 docker) | 启动命令见下 | + +**Backend 启动命令**: + +```bash +cd /opt/foresight/backend && \ + nohup ./.venv-311/bin/python run.py --host 0.0.0.0 \ + >> logs/server.log 2>&1 < /dev/null & \ + disown +``` --- ## 7. 配置项 ```env -# LLM 配置 -LLM_API_KEY= -LLM_BASE_URL=https://api.minimax.chat/v1 -LLM_MODEL_NAME=MiniMax-M2.5 +# ========== LLM ========== +# 智谱 GLM-4-Flash(高吞吐低延迟,OASIS 模拟首选) +LLM_API_KEY=<智谱 API Key> +LLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4/ +LLM_MODEL_NAME=glm-4-flash +# 可选升级:glm-4-flashx / glm-4-air / glm-4-plus(同 endpoint,质量↑速度↓) -# Zep 配置 -ZEP_API_KEY= +# 双 LLM 加速(可选,让两平台用不同 provider 分摊 RPM) +LLM_BOOST_API_KEY= +LLM_BOOST_BASE_URL= +LLM_BOOST_MODEL_NAME= -# 服务端口 -FLASK_PORT=5001 +# ========== Neo4j(自托管 Graphiti 后端) ========== +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=<密码> + +# ========== Graphiti LLM(图谱构建) ========== +GRAPHITI_LLM_API_KEY= +GRAPHITI_LLM_BASE_URL=https://api.siliconflow.cn/v1 +GRAPHITI_LLM_MODEL=Qwen/Qwen2.5-32B-Instruct + +# ========== Embedding ========== +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1 +EMBEDDING_MODEL=BAAI/bge-m3 + +# ========== HuggingFace 镜像(必填,国内服务器) ========== +HF_ENDPOINT=https://hf-mirror.com + +# ========== Flask ========== +FLASK_DEBUG=False ``` --- -## 8. 当前状态与待办 +## 8. v0.3 当前状态与已交付 -### 已完成 -- [x] 前端 UI(Vue 3,支持明暗主题) -- [x] 品牌迁移(MiroFish → Foresight 先见之明) -- [x] 前端部署(腾讯云 COS + CDN + HTTPS) -- [x] Dark Mode 全屏响应式布局 -- [x] 国际化支持(中/英) +### v0.3 已完成(本次大版本前的全部更新) -### 待完成 -- [ ] **后端云部署**:当前后端只能在本地运行(localhost:5001),需部署到腾讯云 CVM 或轻量服务器 -- [ ] **前端 API 地址配置**:设置 `VITE_API_BASE_URL` 指向云端后端 -- [ ] **图谱生成功能验证**:端到端测试完整流水线 -- [ ] **模拟结果持久化**:当前模拟结果存在内存中,需接入数据库 -- [ ] **用户认证**:多用户场景下的身份管理 +#### 基础设施迁移 +- [x] 从 Zep Cloud → 自托管 Graphiti + Neo4j(脱离外部 SaaS 依赖) +- [x] LLM 从 MiniMax M2.7 → 智谱 GLM-4-Flash(速度提升 2-5x) +- [x] Python 3.12 → 3.11.15(uv 管理 .venv-311,解决 camel-oasis 兼容性) +- [x] 服务器从 2C4G → 2C8G(解决 OOM 崩溃) +- [x] 加 4G swap → 总可用内存 ~13G +- [x] 配置 hf-mirror.com(解决 twhin-bert-base 下载卡死) + +#### 性能优化 +- [x] semaphore 30 → 100(每轮 2-3x 加速) +- [x] 找到 200 agents 内存/性能/统计置信度甜点 +- [x] 双平台并行(Twitter + Reddit asyncio.gather) + +#### 新功能 +- [x] **Step 2 加速完成按钮**:profile 生成中可一键停止剩余、用已有进入下一步 +- [x] **Manus 式过程回放界面**(核心 v0.3 交付) + - 后端 `GET /api/simulation/:id/replay` 一次性返回全部回放数据 + - 前端 `/simulation/:id/replay` 三栏布局 + scrubber + 5 档播放速度 + - 工作流时间线 + 当前动作卡 + 滚动 feed + 聚合统计 + - 自动过滤历史 run 残留 actions + +#### Bug 修复 +- [x] **Backend SIGTERM 误杀模拟子进程 bug**:之前重启 Flask 会连带杀子进程,现已修复,可热更新后端代码不影响正在跑的模拟 +- [x] CORS / Neo4j Query / DateTime 序列化等历史 bug + +#### 文档与记忆系统 +- [x] 部署信息全部归档到 M-flow(infra / lessons / services / credentials) +- [x] PRD.md / README.md 重写 + +### v0.4 待完成(路线图详见 §5) + +- [ ] **国内平台抽象层**(P0)— 抖音 / 视频号 / 小红书 / 微博 / 公众号 +- [ ] **Fork 模拟 + A/B 对比**(P1)— 模型复用与对比演化 +- [ ] **多租户 SaaS 改造**(P2)— 子账户体系 + 计费 + 数据隔离 +- [ ] **稳定性运维**(P3)— 子进程心跳 / 内存预算检查 / 自动清理 --- ## 9. 关键文件索引 +### 后端 + | 路径 | 用途 | -|------|------| -| `frontend/src/api/index.js` | API 客户端配置(baseURL) | -| `frontend/src/views/Process.vue` | 图谱构建主界面 | +|---|---| +| `backend/run.py` | Flask 入口 | +| `backend/app/__init__.py` | Flask 初始化(注意:v0.3 已移除 register_cleanup 的破坏性 signal handler) | +| `backend/app/api/graph.py` | 图谱构建 API | +| `backend/app/api/simulation.py` | 模拟 / prepare / start / stop / **replay**(新增 line ~2005) | +| `backend/app/api/report.py` | 报告生成 API | +| `backend/app/services/ontology_generator.py` | LLM 本体生成 | +| `backend/app/services/graph_builder.py` | Graphiti / Neo4j 图谱构建 | +| `backend/app/services/oasis_profile_generator.py` | Agent 画像生成(含加速完成 cancel_check 逻辑) | +| `backend/app/services/simulation_config_generator.py` | 模拟配置生成 | +| `backend/app/services/simulation_manager.py` | 模拟管理(含 accelerate flag) | +| `backend/app/services/simulation_runner.py` | 模拟运行(含 v0.3 register_cleanup 修复) | +| `backend/app/services/report_agent.py` | 报告 ReACT 生成 | +| `backend/scripts/run_parallel_simulation.py` | 双平台并行模拟脚本(semaphore=100) | + +### 前端 + +| 路径 | 用途 | +|---|---| +| `frontend/src/router/index.js` | 路由(v0.3 新增 `/simulation/:id/replay`) | +| `frontend/src/api/simulation.js` | 模拟相关 API client | +| `frontend/src/views/SimulationReplayView.vue` | **v0.3 新增** Manus 式回放主界面 | | `frontend/src/views/SimulationView.vue` | 模拟运行界面 | -| `frontend/src/views/ReportView.vue` | 报告查看界面 | -| `backend/run.py` | 后端入口 | -| `backend/app/api/graph.py` | 图谱相关 API 端点 | -| `backend/app/api/simulation.py` | 模拟相关 API 端点 | -| `backend/app/api/report.py` | 报告相关 API 端点 | -| `backend/app/services/ontology_generator.py` | 本体生成服务(LLM) | -| `backend/app/services/graph_builder.py` | 图谱构建服务(Zep) | -| `backend/app/services/oasis_profile_generator.py` | Agent 画像生成(LLM + Zep) | -| `backend/app/services/simulation_config_generator.py` | 模拟配置生成(LLM) | -| `backend/app/services/report_agent.py` | 报告生成(ReACT,LLM + Zep) | -| `backend/app/utils/llm_client.py` | LLM 客户端封装 | -| `.env` | API Keys 和配置 | +| `frontend/src/views/SimulationRunView.vue` | 模拟启动界面 | +| `frontend/src/views/ReportView.vue` | 报告查看 | +| `frontend/src/components/Step2EnvSetup.vue` | Step 2 环境设置(v0.3 新增加速完成按钮) | + +### 部署 / 运维 + +| 路径 | 说明 | +|---|---| +| `.env` | API Keys 与配置(智谱 / Neo4j / SiliconFlow / HF mirror) | +| `docker-compose.yml` | Neo4j 容器配置 | +| `~/.claude/projects/.../memory/` | M-flow 记忆系统索引(OpenClaw 内部) | + +### 服务器(远程) + +| 路径 | 说明 | +|---|---| +| `/opt/foresight/backend/` | 后端代码 | +| `/opt/foresight/backend/.venv-311/` | Python 3.11 虚拟环境(5.1G) | +| `/opt/foresight/backend/uploads/simulations/sim_/` | 单次模拟数据目录 | +| `/opt/foresight/backend/logs/server.log` | Flask 日志 | +| `/opt/foresight/.env` | 服务器配置 | +| `/etc/nginx/sites-enabled/foresight-api` | Nginx 反代配置 | + +--- + +## 10. 已知限制与边界 + +| 限制 | 说明 | 缓解策略 | +|---|---|---| +| Agent 数 ≤ 200(双平台 2C8G) | 超过会 OOM | 升级 4C16G / 单平台 / 拆分 batch | +| 国内平台未支持 | 当前只有 Twitter+Reddit | v0.4 P0 | +| 单租户 | 多客户共用一套数据 | v0.4 P2 | +| 子进程心跳缺失 | sim 挂了 state 仍是 running | v0.4 P3 | +| 报告生成 Token 消耗大 | 单次 80-150K | 优化 prompt / 缓存图谱搜索结果 | +| 重启 sim 需手动 reset state | 半死 state 阻塞下次启动 | v0.4 P3 加 force-restart 按钮 | + +--- + +## 附录 A:v0.3 关键运维教训 + +1. **Python 版本约束必须 pre-flight 检查**:`requirements.txt` 改动后立刻验证 venv 兼容 +2. **国内服务器装包必走腾讯云镜像**:`uv pip install --index-url http://mirrors.tencentyun.com/pypi/simple --trusted-host mirrors.tencentyun.com` +3. **HuggingFace 模型必配 `HF_ENDPOINT=https://hf-mirror.com`**:否则 OASIS 启动卡死 +4. **遇到模拟卡死先看 simulation.log 最后 10 行**,不要先猜 LLM 慢 +5. **8G 内存只能跑 200 agents 双平台**,503 会 OOM。要 503 双平台需升级 16G +6. **重启 Flask 不再杀子进程**(v0.3 修复后),可以安全热更新代码 +7. **GLM-4-Flash 是 OASIS 场景的最优 LLM**:旗舰模型反而是反向优化(每次调用 2-5s 太慢) + +## 附录 B:决策日志 + +| 日期 | 决策 | 原因 | +|---|---|---| +| 2026-04-13 | 从 Zep Cloud → Graphiti+Neo4j | 脱离外部依赖 | +| 2026-04-14 | LLM 切 GLM-4-Flash | MiniMax 速度不够 | +| 2026-04-14 | uv + Python 3.11 重建 venv | camel-oasis 不支持 3.12 | +| 2026-04-14 | 服务器升级 2C8G | 3.6G 跑不动 OASIS | +| 2026-04-14 | 配 hf-mirror.com | 服务器连不上 huggingface | +| 2026-04-15 | 修复 SIGTERM 误杀 bug | 热更新后端不再中断模拟 | +| 2026-04-15 | 200 agents 设为甜点 | 8G 容量 + 95% 置信度足够 | +| 2026-04-15 | 上线 Manus 式 replay UI | 给客户演示 + 复盘工具 | +| 2026-04-15 | v0.4 路线图:国内平台 + SaaS | 用户战略需求 | diff --git a/README-ZH.md b/README-ZH.md index 2dac5df8..ea2770cd 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -85,11 +85,12 @@ Foresight 致力于打造映射现实的群体智能镜像,通过捕捉个体 ## 🔄 工作流程 -1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG构建 -2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数 -3. **开始模拟**:双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆 -4. **报告生成**:ReportAgent拥有丰富的工具集与模拟后环境进行深度交互 -5. **深度互动**:与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话 +1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG(Graphiti + Neo4j)构建 +2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数(支持「加速完成」按钮) +3. **开始模拟**:双平台并行模拟(Twitter + Reddit) & 自动解析预测需求 & 动态更新时序记忆 +4. **报告生成**:ReportAgent 拥有丰富的工具集与模拟后环境进行深度交互 +5. **深度互动**:与模拟世界中的任意一位进行对话 & 与 ReportAgent 进行对话 +6. **过程回放(v0.3 新增)**:Manus 式可拖拽时间轴,回放整个工作流和每一轮 agent 行为,方便复盘和演示 ## 🚀 快速开始 diff --git a/README.md b/README.md index 3aef4111..b498c730 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,12 @@ Click the image to watch Foresight's deep prediction of the lost ending based on ## 🔄 Workflow -1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction -2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection -3. **Simulation**: Dual-platform parallel simulation & Auto-parse prediction requirements & Dynamic temporal memory updates +1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction (Graphiti + Neo4j) +2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection (with "Skip & Continue" button) +3. **Simulation**: Dual-platform parallel simulation (Twitter + Reddit) & Auto-parse prediction requirements & Dynamic temporal memory updates 4. **Report Generation**: ReportAgent with rich toolset for deep interaction with post-simulation environment 5. **Deep Interaction**: Chat with any agent in the simulated world & Interact with ReportAgent +6. **Process Replay (v0.3 New)**: Manus-style draggable timeline to replay the entire workflow and every round of agent actions — perfect for retros and demos ## 🚀 Quick Start diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b77a35c2..96aebdec 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -63,10 +63,11 @@ def create_app(config_class=Config): return response # 注册蓝图 - from .api import graph_bp, simulation_bp, report_bp + from .api import graph_bp, simulation_bp, report_bp, usage_bp app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(simulation_bp, url_prefix='/api/simulation') app.register_blueprint(report_bp, url_prefix='/api/report') + app.register_blueprint(usage_bp, url_prefix='/api/usage') # 健康检查 @app.route('/health') diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ffda743a..bf2801d9 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -7,8 +7,10 @@ from flask import Blueprint graph_bp = Blueprint('graph', __name__) simulation_bp = Blueprint('simulation', __name__) report_bp = Blueprint('report', __name__) +usage_bp = Blueprint('usage', __name__) from . import graph # noqa: E402, F401 from . import simulation # noqa: E402, F401 from . import report # noqa: E402, F401 +from . import usage # noqa: E402, F401 diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py index 766df089..fbd903c6 100644 --- a/backend/app/api/graph.py +++ b/backend/app/api/graph.py @@ -154,7 +154,11 @@ def generate_ontology(): simulation_requirement = request.form.get('simulation_requirement', '') project_name = request.form.get('project_name', 'Unnamed Project') additional_context = request.form.get('additional_context', '') - + + # token 追踪:标记当前 stage + from ..utils import token_tracker + token_tracker.set_stage("step1_ontology") + logger.debug(f"项目名称: {project_name}") logger.debug(f"模拟需求: {simulation_requirement[:100]}...") @@ -282,7 +286,11 @@ def build_graph(): """ try: logger.info("=== 开始构建图谱 ===") - + + # token 追踪:标记当前 stage + from ..utils import token_tracker + token_tracker.set_stage("step2_graph_build") + # 检查配置 errors = [] if not Config.ZEP_API_KEY: diff --git a/backend/app/api/report.py b/backend/app/api/report.py index 35875830..284d58cf 100644 --- a/backend/app/api/report.py +++ b/backend/app/api/report.py @@ -48,8 +48,12 @@ def generate_report(): } """ try: + # token 追踪:报告生成阶段 + from ..utils import token_tracker + token_tracker.set_stage("step5_report") + data = request.get_json() or {} - + simulation_id = data.get('simulation_id') if not simulation_id: return jsonify({ diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index a294ec65..cff6f832 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -4,6 +4,8 @@ Step2: Zep实体读取与过滤、OASIS模拟准备与运行(全程自动化 """ import os +import json +import csv import traceback from flask import request, jsonify, send_file @@ -401,8 +403,12 @@ def prepare_simulation(): import os from ..models.task import TaskManager, TaskStatus from ..config import Config - + from ..utils import token_tracker + try: + # token 追踪:profile + config 生成阶段统一归到 step3_prepare + token_tracker.set_stage("step3_prepare") + data = request.get_json() or {} simulation_id = data.get('simulation_id') @@ -639,6 +645,53 @@ def prepare_simulation(): }), 500 +@simulation_bp.route('/prepare/accelerate', methods=['POST']) +def accelerate_prepare(): + """ + 加速完成 Agent 人设生成 + + 向正在运行的 prepare 任务发送"加速完成"信号: + - 停止继续生成未开始的 profile + - 用已生成的 profile 继续后续流程(config 生成等) + + 请求(JSON): + { "simulation_id": "sim_xxxx" } + """ + try: + data = request.get_json() or {} + simulation_id = data.get('simulation_id') + if not simulation_id: + return jsonify({ + "success": False, + "error": t('api.requireSimulationId') + }), 400 + + manager = SimulationManager() + state = manager.get_simulation(simulation_id) + if not state: + return jsonify({ + "success": False, + "error": t('api.simulationNotFound', id=simulation_id) + }), 404 + + manager.request_accelerate(simulation_id) + logger.info(f"已收到加速完成请求: simulation_id={simulation_id}") + + return jsonify({ + "success": True, + "data": { + "simulation_id": simulation_id, + "message": "加速完成信号已发送,后台将在下一个检查点停止生成剩余人设" + } + }) + except Exception as e: + logger.error(f"加速完成请求失败: {str(e)}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + @simulation_bp.route('/prepare/status', methods=['POST']) def get_prepare_status(): """ @@ -1955,6 +2008,337 @@ def get_simulation_timeline(simulation_id: str): }), 500 +def _find_latest_simulation_start(sim_dir: str) -> str: + """ + 扫描 twitter/actions.jsonl 和 reddit/actions.jsonl,找出最近一次 simulation_start 事件的时间戳。 + 用于过滤掉历史 run 残留的 actions(jsonl 是 append-only,多次 run 会累积)。 + 返回 ISO 格式时间戳字符串,找不到则返回空串。 + """ + latest = "" + for sub in ("twitter", "reddit"): + path = os.path.join(sim_dir, sub, "actions.jsonl") + if not os.path.exists(path): + continue + try: + with open(path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line or '"simulation_start"' not in line: + continue + try: + evt = json.loads(line) + except Exception: + continue + if evt.get("event_type") != "simulation_start": + continue + ts = evt.get("timestamp", "") + if ts and ts > latest: + latest = ts + except Exception: + continue + return latest + + +def _extract_entity_type_names(ontology): + """从 ontology 字段中提取实体类型名称列表,兼容 dict / list / dict-of-list 多种格式""" + if not ontology: + return [] + if isinstance(ontology, list): + return [str(x) for x in ontology[:50]] + if isinstance(ontology, dict): + et = ontology.get("entity_types") + if isinstance(et, dict): + return list(et.keys())[:50] + if isinstance(et, list): + return [item.get("name", str(item)) if isinstance(item, dict) else str(item) for item in et[:50]] + return [] + + +@simulation_bp.route('//replay', methods=['GET']) +def get_simulation_replay(simulation_id: str): + """ + 获取模拟完整回放数据(Manus 式过程回放) + + 一次性返回展示整个 Foresight 工作流所需的全部数据,前端无需多次请求: + - simulation: 基本状态 + - project: 文档/需求/图谱信息 + - workflow: 5 步工作流时间线(每步 status + metadata) + - config: 模拟配置摘要(时间配置 + 事件配置) + - agents: agent 精简列表(id/name/username/profession/bio) + - rounds: 每轮动作列表 + 每轮统计(action_types, by_platform, active_agents_count) + - aggregate: 全局聚合(总动作数、类型分布、top agents) + + 用途:前端 /simulation/:id/replay 路由,播放整个模拟过程 + """ + try: + manager = SimulationManager() + state = manager.get_simulation(simulation_id) + if not state: + return jsonify({ + "success": False, + "error": f"simulation not found: {simulation_id}" + }), 404 + + sim_dir = manager._get_simulation_dir(simulation_id) + + # ---------- 1. 项目信息 ---------- + project_info = None + try: + project = ProjectManager.get_project(state.project_id) + if project: + project_info = { + "project_id": project.project_id, + "name": project.name, + "status": project.status.value if hasattr(project.status, 'value') else str(project.status), + "graph_id": project.graph_id, + "simulation_requirement": project.simulation_requirement, + "files": [ + { + "filename": f.get("original_filename") or f.get("filename"), + "size": f.get("size"), + } + for f in (project.files or []) + ], + "total_text_length": project.total_text_length, + "analysis_summary": (project.analysis_summary or "")[:500] if project.analysis_summary else None, + "ontology_entity_types": _extract_entity_type_names(project.ontology), + } + except Exception as e: + logger.warning(f"replay: 读取项目信息失败 {state.project_id}: {e}") + + # ---------- 2. 模拟配置 ---------- + sim_config = {} + config_path = os.path.join(sim_dir, "simulation_config.json") + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + sim_config = json.load(f) + except Exception as e: + logger.warning(f"replay: 读取 simulation_config.json 失败: {e}") + + time_config = sim_config.get("time_config", {}) or {} + event_config = sim_config.get("event_config", {}) or {} + agent_configs = sim_config.get("agent_configs", []) or [] + minutes_per_round = time_config.get("minutes_per_round", 60) + + # ---------- 3. Agents (精简版,用于回放显示) ---------- + agents = [] + reddit_path = os.path.join(sim_dir, "reddit_profiles.json") + twitter_path = os.path.join(sim_dir, "twitter_profiles.csv") + + if os.path.exists(reddit_path): + try: + with open(reddit_path, 'r', encoding='utf-8') as f: + reddit_profiles = json.load(f) + for idx, p in enumerate(reddit_profiles): + agents.append({ + "id": p.get("user_id", idx), + "name": p.get("name") or p.get("username") or f"agent_{idx}", + "username": p.get("username") or p.get("user_name") or f"agent_{idx}", + "profession": p.get("profession"), + "bio": (p.get("bio") or "")[:200], + "interested_topics": p.get("interested_topics") or [], + }) + except Exception as e: + logger.warning(f"replay: 读取 reddit_profiles.json 失败: {e}") + elif os.path.exists(twitter_path): + try: + with open(twitter_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for idx, row in enumerate(reader): + agents.append({ + "id": int(row.get("user_id", idx) or idx), + "name": row.get("name") or row.get("username") or f"agent_{idx}", + "username": row.get("username") or row.get("user_name") or f"agent_{idx}", + "profession": row.get("profession"), + "bio": (row.get("bio") or "")[:200], + "interested_topics": [], + }) + except Exception as e: + logger.warning(f"replay: 读取 twitter_profiles.csv 失败: {e}") + + # ---------- 4. Actions + rounds ---------- + # 找出最近一次 simulation_start 的时间戳,过滤掉之前 run 残留的 actions + # (actions.jsonl 是 append-only,多次 run 会累积;只显示最新这次) + latest_start_ts = _find_latest_simulation_start(sim_dir) + + all_actions = SimulationRunner.get_all_actions(simulation_id) + if latest_start_ts: + all_actions = [a for a in all_actions if a.timestamp >= latest_start_ts] + # 按时间戳升序(回放需要从 round 0 到最后) + all_actions.sort(key=lambda a: (a.round_num, a.timestamp)) + + rounds_map = {} + for action in all_actions: + r = action.round_num + if r not in rounds_map: + simulated_minutes = r * minutes_per_round + rounds_map[r] = { + "round_num": r, + "simulated_hour": (simulated_minutes // 60) % 24, + "simulated_day": simulated_minutes // (60 * 24) + 1, + "first_timestamp": action.timestamp, + "last_timestamp": action.timestamp, + "actions": [], + "_active_agents": set(), + "_by_type": {}, + "_by_platform": {"twitter": 0, "reddit": 0}, + } + rd = rounds_map[r] + rd["actions"].append(action.to_dict()) + rd["last_timestamp"] = action.timestamp + rd["_active_agents"].add(action.agent_id) + rd["_by_type"][action.action_type] = rd["_by_type"].get(action.action_type, 0) + 1 + if action.platform in rd["_by_platform"]: + rd["_by_platform"][action.platform] += 1 + + rounds_list = [] + for r in sorted(rounds_map.keys()): + rd = rounds_map[r] + rounds_list.append({ + "round_num": rd["round_num"], + "simulated_hour": rd["simulated_hour"], + "simulated_day": rd["simulated_day"], + "first_timestamp": rd["first_timestamp"], + "last_timestamp": rd["last_timestamp"], + "actions": rd["actions"], + "stats": { + "total_actions": len(rd["actions"]), + "active_agents_count": len(rd["_active_agents"]), + "by_type": rd["_by_type"], + "by_platform": rd["_by_platform"], + }, + }) + + # ---------- 5. Aggregate ---------- + total_actions = len(all_actions) + action_type_dist = {} + agent_action_count = {} + platform_totals = {"twitter": 0, "reddit": 0} + for a in all_actions: + action_type_dist[a.action_type] = action_type_dist.get(a.action_type, 0) + 1 + key = (a.agent_id, a.agent_name) + agent_action_count[key] = agent_action_count.get(key, 0) + 1 + if a.platform in platform_totals: + platform_totals[a.platform] += 1 + + top_agents = sorted( + [{"agent_id": k[0], "agent_name": k[1], "count": v} for k, v in agent_action_count.items()], + key=lambda x: x["count"], + reverse=True, + )[:10] + + # ---------- 6. Workflow 时间线 ---------- + status_str = state.status.value if hasattr(state.status, 'value') else str(state.status) + workflow = [ + { + "step": 1, + "name": "文档录入与需求确认", + "status": "completed" if project_info else "pending", + "metadata": { + "files": project_info["files"] if project_info else [], + "requirement": project_info["simulation_requirement"] if project_info else None, + "text_length": project_info["total_text_length"] if project_info else 0, + "analysis_summary": project_info["analysis_summary"] if project_info else None, + }, + }, + { + "step": 2, + "name": "知识图谱构建", + "status": "completed" if state.entities_count > 0 else "pending", + "metadata": { + "graph_id": state.graph_id, + "entity_types": state.entity_types, + "entities_count": state.entities_count, + "ontology_entity_types": project_info["ontology_entity_types"] if project_info else [], + }, + }, + { + "step": 3, + "name": "Agent 人设生成", + "status": "completed" if state.profiles_count > 0 else "pending", + "metadata": { + "profiles_count": state.profiles_count, + "agents_loaded": len(agents), + }, + }, + { + "step": 4, + "name": "模拟配置生成", + "status": "completed" if state.config_generated else "pending", + "metadata": { + "config_reasoning": (state.config_reasoning or "")[:500] if state.config_reasoning else None, + "total_simulation_hours": time_config.get("total_simulation_hours"), + "minutes_per_round": minutes_per_round, + "peak_hours": time_config.get("peak_hours"), + "agents_per_hour_min": time_config.get("agents_per_hour_min"), + "agents_per_hour_max": time_config.get("agents_per_hour_max"), + "initial_posts_count": len(event_config.get("initial_posts", [])), + }, + }, + { + "step": 5, + "name": "双平台模拟运行", + "status": status_str, + "metadata": { + "total_rounds_executed": len(rounds_list), + "total_actions": total_actions, + "twitter_enabled": state.enable_twitter, + "reddit_enabled": state.enable_reddit, + "by_platform": platform_totals, + "current_run_started_at": latest_start_ts or state.created_at, + "simulation_created_at": state.created_at, + "updated_at": state.updated_at, + }, + }, + ] + + return jsonify({ + "success": True, + "data": { + "simulation": { + "simulation_id": state.simulation_id, + "project_id": state.project_id, + "graph_id": state.graph_id, + "status": status_str, + "enable_twitter": state.enable_twitter, + "enable_reddit": state.enable_reddit, + "entities_count": state.entities_count, + "profiles_count": state.profiles_count, + "created_at": state.created_at, + "updated_at": state.updated_at, + }, + "project": project_info, + "workflow": workflow, + "config": { + "time_config": time_config, + "event_config": { + "initial_posts": event_config.get("initial_posts", []), + "events": event_config.get("events", []), + }, + "agent_configs_count": len(agent_configs), + }, + "agents": agents, + "rounds": rounds_list, + "aggregate": { + "total_actions": total_actions, + "rounds_with_actions": len(rounds_list), + "action_type_distribution": action_type_dist, + "platform_totals": platform_totals, + "top_agents": top_agents, + }, + }, + }) + + except Exception as e: + logger.error(f"获取回放数据失败: {str(e)}") + return jsonify({ + "success": False, + "error": str(e), + "traceback": traceback.format_exc() + }), 500 + + @simulation_bp.route('//agent-stats', methods=['GET']) def get_agent_stats(simulation_id: str): """ diff --git a/backend/app/api/usage.py b/backend/app/api/usage.py new file mode 100644 index 00000000..2608006a --- /dev/null +++ b/backend/app/api/usage.py @@ -0,0 +1,118 @@ +""" +Token 用量查询 API(v0.3 新增) + +提供 LLM token 消耗的实时统计与成本估算。 +内部用途,销售给客户的版本可移除此 blueprint 注册。 +""" + +from flask import jsonify, request +from . import usage_bp +from ..utils import token_tracker +from ..utils.logger import get_logger + +logger = get_logger('foresight.api.usage') + + +@usage_bp.route('/summary', methods=['GET']) +def get_usage_summary(): + """ + 获取当前累计 token 用量摘要 + + 返回: + { + "success": true, + "data": { + "stages": { + "": { + "by_model": {...}, + "prompt_tokens": int, + "completion_tokens": int, + "calls": int, + "estimated_cost_cny": float + } + }, + "total": {...}, + "current_stage": "..." + } + } + """ + try: + return jsonify({ + "success": True, + "data": token_tracker.get_summary(), + }) + except Exception as e: + logger.error(f"获取 token 用量失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@usage_bp.route('/reset', methods=['POST']) +def reset_usage(): + """ + 清空 token 用量统计 + + Body (可选): + {"stage": ""} // 不填则全部清空 + """ + try: + data = request.get_json(silent=True) or {} + stage = data.get("stage") + token_tracker.reset(stage) + return jsonify({ + "success": True, + "data": {"reset_stage": stage or "all"}, + }) + except Exception as e: + logger.error(f"重置 token 用量失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@usage_bp.route('/estimate-simulation', methods=['GET']) +def estimate_simulation(): + """ + 估算 OASIS 模拟子进程的 token 消耗(无法精确追踪,只能估算) + + Query params: + rounds: 模拟轮数 (默认 15) + active_agents_per_round: 每轮平均激活 agent 数 (默认 10) + llm_calls_per_action: 每动作 LLM 调用次数 (默认 4) + prompt_tokens: 平均 prompt token (默认 800) + completion_tokens: 平均 completion token (默认 200) + model: 模型名 (默认 glm-4-flash) + """ + try: + rounds = request.args.get('rounds', 15, type=int) + agents = request.args.get('active_agents_per_round', 10, type=int) + calls = request.args.get('llm_calls_per_action', 4, type=int) + prompt = request.args.get('prompt_tokens', 800, type=int) + completion = request.args.get('completion_tokens', 200, type=int) + model = request.args.get('model', 'glm-4-flash') + + result = token_tracker.estimate_simulation_cost( + rounds=rounds, + active_agents_per_round_avg=agents, + llm_calls_per_action=calls, + avg_prompt_tokens_per_call=prompt, + avg_completion_tokens_per_call=completion, + model=model, + ) + return jsonify({"success": True, "data": result}) + except Exception as e: + logger.error(f"估算模拟 token 失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@usage_bp.route('/set-stage', methods=['POST']) +def set_stage(): + """ + 手动设置当前 stage(用于测试或非自动埋点的场景) + + Body: {"stage": ""} + """ + try: + data = request.get_json(silent=True) or {} + stage = data.get("stage") + token_tracker.set_stage(stage) + return jsonify({"success": True, "data": {"current_stage": stage}}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py index dd7b3e55..46df75db 100644 --- a/backend/app/services/oasis_profile_generator.py +++ b/backend/app/services/oasis_profile_generator.py @@ -819,7 +819,8 @@ class OasisProfileGenerator: graph_id: Optional[str] = None, parallel_count: int = 5, realtime_output_path: Optional[str] = None, - output_platform: str = "reddit" + output_platform: str = "reddit", + cancel_check: Optional[callable] = None ) -> List[OasisAgentProfile]: """ 批量从实体生成Agent Profile(支持并行生成) @@ -918,6 +919,8 @@ class OasisProfileGenerator: print(f"开始生成Agent人设 - 共 {total} 个实体,并行数: {parallel_count}") print(f"{'='*60}\n") + cancelled = False + # 使用线程池并行执行 with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor: # 提交所有任务 @@ -925,12 +928,22 @@ class OasisProfileGenerator: executor.submit(generate_single_profile, idx, entity): (idx, entity) for idx, entity in enumerate(entities) } - + # 收集结果 for future in concurrent.futures.as_completed(future_to_entity): + # 检测到加速信号:取消未开始的任务并结束收集 + if cancel_check and cancel_check(): + cancelled = True + logger.info("检测到加速完成信号,取消剩余未完成的人设生成任务") + print("\n[加速完成] 用户请求立即结束人设生成,取消剩余任务...") + for f in future_to_entity: + if not f.done(): + f.cancel() + break + idx, entity = future_to_entity[future] entity_type = entity.get_entity_type() or "Entity" - + try: result_idx, profile, error = future.result() profiles[result_idx] = profile @@ -970,11 +983,21 @@ class OasisProfileGenerator: # 实时写入文件(即使是备用人设) save_profiles_realtime() + # 过滤掉未完成的 None 占位符(加速完成或任务取消时可能存在) + final_profiles = [p for p in profiles if p is not None] + + # 加速完成后再写一次实时文件,确保文件内容与返回值一致 + if cancelled and realtime_output_path: + save_profiles_realtime() + print(f"\n{'='*60}") - print(f"人设生成完成!共生成 {len([p for p in profiles if p])} 个Agent") + if cancelled: + print(f"人设生成已加速完成!共生成 {len(final_profiles)} 个Agent(原计划 {total} 个)") + else: + print(f"人设生成完成!共生成 {len(final_profiles)} 个Agent") print(f"{'='*60}\n") - - return profiles + + return final_profiles def _print_generated_profile(self, entity_name: str, entity_type: str, profile: OasisAgentProfile): """实时输出生成的人设到控制台(完整内容,不截断)""" diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 625dd0c3..c1c2285a 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -125,10 +125,25 @@ class SimulationManager: # 模拟数据存储目录 SIMULATION_DATA_DIR = os.path.join( - os.path.dirname(__file__), + os.path.dirname(__file__), '../../uploads/simulations' ) - + + # 加速完成标志:simulation_id -> True 表示用户请求立即结束 profile 生成 + _accelerate_flags: Dict[str, bool] = {} + + @classmethod + def request_accelerate(cls, simulation_id: str) -> None: + cls._accelerate_flags[simulation_id] = True + + @classmethod + def is_accelerate_requested(cls, simulation_id: str) -> bool: + return cls._accelerate_flags.get(simulation_id, False) + + @classmethod + def clear_accelerate(cls, simulation_id: str) -> None: + cls._accelerate_flags.pop(simulation_id, None) + def __init__(self): # 确保目录存在 os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True) @@ -262,7 +277,10 @@ class SimulationManager: state = self._load_simulation_state(simulation_id) if not state: raise ValueError(f"模拟不存在: {simulation_id}") - + + # 重置加速标志,防止上一轮残留 + self.clear_accelerate(simulation_id) + try: state.status = SimulationStatus.PREPARING self._save_simulation_state(state) @@ -343,9 +361,19 @@ class SimulationManager: graph_id=state.graph_id, # 传入graph_id用于Zep检索 parallel_count=parallel_profile_count, # 并行生成数量 realtime_output_path=realtime_output_path, # 实时保存路径 - output_platform=realtime_platform # 输出格式 + output_platform=realtime_platform, # 输出格式 + cancel_check=lambda sid=simulation_id: self.is_accelerate_requested(sid) ) - + + # 若触发了加速完成,后续 config 生成需要只使用实际生成了 profile 的实体, + # 避免实体数(filtered.entities)与 profile 数不一致导致 config 与 profile 对不上 + if len(profiles) < len(filtered.entities): + generated_uuids = {p.source_entity_uuid for p in profiles if p.source_entity_uuid} + filtered.entities = [e for e in filtered.entities if e.uuid in generated_uuids] + filtered.filtered_count = len(filtered.entities) + state.entities_count = filtered.filtered_count + logger.info(f"加速完成:实际使用 {len(filtered.entities)} 个实体继续后续流程") + state.profiles_count = len(profiles) # 保存Profile文件(注意:Twitter使用CSV格式,Reddit使用JSON格式) @@ -444,9 +472,10 @@ class SimulationManager: logger.info(f"模拟准备完成: {simulation_id}, " f"entities={state.entities_count}, profiles={state.profiles_count}") - + + self.clear_accelerate(simulation_id) return state - + except Exception as e: logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}") import traceback @@ -454,6 +483,7 @@ class SimulationManager: state.status = SimulationStatus.FAILED state.error = str(e) self._save_simulation_state(state) + self.clear_accelerate(simulation_id) raise def get_simulation(self, simulation_id: str) -> Optional[SimulationState]: diff --git a/backend/app/services/simulation_runner.py b/backend/app/services/simulation_runner.py index 950b9d7f..e89cd966 100644 --- a/backend/app/services/simulation_runner.py +++ b/backend/app/services/simulation_runner.py @@ -1287,74 +1287,28 @@ class SimulationRunner: @classmethod def register_cleanup(cls): """ - 注册清理函数 - - 在 Flask 应用启动时调用,确保服务器关闭时清理所有模拟进程 + 注册清理函数(已禁用) + + 运维修复(2026-04-14):原实现在 Flask 收到 SIGTERM/SIGINT/SIGHUP 或 + 进程退出时,会调用 cleanup_all_simulations() 杀掉所有正在运行的模拟 + 子进程。这导致重启 Flask backend 必然中断所有进行中的模拟,运维成本 + 极高。 + + 子进程已通过 subprocess.Popen(..., start_new_session=True) 获得独立 + 的 session/PGID,不会因 Flask 父进程收到信号而被顺带杀掉。因此 Flask + 退出时无需也不应该主动清理这些子进程 —— 它们可以继续跑完,Flask 重 + 启后通过 _load_run_state 等机制重新接管。 + + 仅当用户显式调用 stop_simulation 时才会终止子进程(那条路径不经过 + 这里)。所以本方法保留为空实现,避免破坏调用方。 """ global _cleanup_registered - if _cleanup_registered: return - - # Flask debug 模式下,只在 reloader 子进程中注册清理(实际运行应用的进程) - # WERKZEUG_RUN_MAIN=true 表示是 reloader 子进程 - # 如果不是 debug 模式,则没有这个环境变量,也需要注册 - is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true' - is_debug_mode = os.environ.get('FLASK_DEBUG') == '1' or os.environ.get('WERKZEUG_RUN_MAIN') is not None - - # 在 debug 模式下,只在 reloader 子进程中注册;非 debug 模式下始终注册 - if is_debug_mode and not is_reloader_process: - _cleanup_registered = True # 标记已注册,防止子进程再次尝试 - return - - # 保存原有的信号处理器 - original_sigint = signal.getsignal(signal.SIGINT) - original_sigterm = signal.getsignal(signal.SIGTERM) - # SIGHUP 只在 Unix 系统存在(macOS/Linux),Windows 没有 - original_sighup = None - has_sighup = hasattr(signal, 'SIGHUP') - if has_sighup: - original_sighup = signal.getsignal(signal.SIGHUP) - - def cleanup_handler(signum=None, frame=None): - """信号处理器:先清理模拟进程,再调用原处理器""" - # 只有在有进程需要清理时才打印日志 - if cls._processes or cls._graph_memory_enabled: - logger.info(f"收到信号 {signum},开始清理...") - cls.cleanup_all_simulations() - - # 调用原有的信号处理器,让 Flask 正常退出 - if signum == signal.SIGINT and callable(original_sigint): - original_sigint(signum, frame) - elif signum == signal.SIGTERM and callable(original_sigterm): - original_sigterm(signum, frame) - elif has_sighup and signum == signal.SIGHUP: - # SIGHUP: 终端关闭时发送 - if callable(original_sighup): - original_sighup(signum, frame) - else: - # 默认行为:正常退出 - sys.exit(0) - else: - # 如果原处理器不可调用(如 SIG_DFL),则使用默认行为 - raise KeyboardInterrupt - - # 注册 atexit 处理器(作为备用) - atexit.register(cls.cleanup_all_simulations) - - # 注册信号处理器(仅在主线程中) - try: - # SIGTERM: kill 命令默认信号 - signal.signal(signal.SIGTERM, cleanup_handler) - # SIGINT: Ctrl+C - signal.signal(signal.SIGINT, cleanup_handler) - # SIGHUP: 终端关闭(仅 Unix 系统) - if has_sighup: - signal.signal(signal.SIGHUP, cleanup_handler) - except ValueError: - # 不在主线程中,只能使用 atexit - logger.warning("无法注册信号处理器(不在主线程),仅使用 atexit") - + logger.info( + "register_cleanup 已禁用:Flask 退出时不再自动清理模拟子进程," + "避免重启 backend 时误杀正在运行的模拟。子进程独立 session 存活。" + ) _cleanup_registered = True @classmethod diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 6c1a81f4..b4f79faa 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -60,8 +60,21 @@ class LLMClient: if response_format: kwargs["response_format"] = response_format - + response = self.client.chat.completions.create(**kwargs) + # Token 追踪(v0.3 新增):记录每次调用的 token 消耗,按当前 stage 归类 + try: + from . import token_tracker + usage = getattr(response, "usage", None) + if usage is not None: + token_tracker.record_usage( + model=self.model, + prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0), + completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0), + ) + except Exception: + pass # 永不阻塞主流程 + content = response.choices[0].message.content # 部分模型(如MiniMax M2.5)会在content中包含思考内容,需要移除 content = re.sub(r'[\s\S]*?', '', content).strip() diff --git a/backend/app/utils/token_tracker.py b/backend/app/utils/token_tracker.py new file mode 100644 index 00000000..99093cdc --- /dev/null +++ b/backend/app/utils/token_tracker.py @@ -0,0 +1,238 @@ +""" +Token 用量追踪器(v0.3 新增) + +进程内全局单例,按 stage 维度统计 LLM token 消耗。 +- LLMClient 自动 record 每次 API call 的 prompt/completion tokens +- 调用方在 stage 切换时 set_stage("step1_ontology") +- 通过 GET /api/usage/summary 查询 +- 用于成本估算和销售定价 + +说明: +1. 这是 Flask 后端进程内的统计,不包含 OASIS 模拟子进程的 LLM 消耗 + (camel-ai 用自己的 LLM client,需另外估算) +2. 进程重启会丢失数据,如需持久化可用 reset() 前先 dump 到文件 +3. 价格为每 1K tokens 的 CNY 价格,参考各 provider 官网 (2026-04 价格) +""" + +import threading +from typing import Optional, Dict, Any + +_lock = threading.Lock() + +# 全局状态:{stage: {model: {prompt_tokens, completion_tokens, calls}}} +_stage_usage: Dict[str, Dict[str, Dict[str, int]]] = {} +_current_stage: Optional[str] = None + +# 模型定价表(CNY per 1K tokens, prompt / completion) +# 来源:各 provider 2026-04 官网价格 +PRICING = { + # 智谱 GLM 系列 + "glm-4-flash": {"prompt": 0.0001, "completion": 0.0001}, + "glm-4-flashx": {"prompt": 0.001, "completion": 0.001}, + "glm-4-air": {"prompt": 0.001, "completion": 0.001}, + "glm-4-plus": {"prompt": 0.05, "completion": 0.05}, + "glm-4.6": {"prompt": 0.05, "completion": 0.05}, + "glm-5.1": {"prompt": 0.1, "completion": 0.1}, + # SiliconFlow 免费 + "qwen/qwen2.5-32b-instruct": {"prompt": 0.0, "completion": 0.0}, + "qwen2.5-32b-instruct": {"prompt": 0.0, "completion": 0.0}, + "baai/bge-m3": {"prompt": 0.0, "completion": 0.0}, + # MiniMax + "minimax-m2.7-highspeed": {"prompt": 0.001, "completion": 0.001}, + "minimax-m2.5": {"prompt": 0.001, "completion": 0.002}, + # Anthropic / OpenAI(备用) + "gpt-4o-mini": {"prompt": 0.0011, "completion": 0.0044}, + "gpt-4o": {"prompt": 0.018, "completion": 0.072}, + "claude-haiku-4-5": {"prompt": 0.0072, "completion": 0.036}, + "claude-sonnet-4-6": {"prompt": 0.022, "completion": 0.108}, + # 兜底 + "default": {"prompt": 0.001, "completion": 0.002}, +} + + +def _get_pricing(model: str) -> Dict[str, float]: + if not model: + return PRICING["default"] + key = model.lower().strip() + if key in PRICING: + return PRICING[key] + # 模糊匹配(去掉版本号) + for k in PRICING: + if k != "default" and (key.startswith(k) or k.startswith(key)): + return PRICING[k] + return PRICING["default"] + + +def set_stage(name: Optional[str]) -> None: + """设置当前 stage 名称,后续的 LLM 调用会归到这个 stage 下""" + global _current_stage + with _lock: + _current_stage = name + + +def get_stage() -> Optional[str]: + return _current_stage + + +def record_usage( + model: str, + prompt_tokens: int, + completion_tokens: int, + stage: Optional[str] = None, +) -> None: + """记录一次 LLM 调用的 token 消耗""" + if not isinstance(prompt_tokens, int) or not isinstance(completion_tokens, int): + return + s = stage or _current_stage or "unknown" + with _lock: + stage_bucket = _stage_usage.setdefault(s, {}) + model_bucket = stage_bucket.setdefault( + model or "unknown", + {"prompt_tokens": 0, "completion_tokens": 0, "calls": 0}, + ) + model_bucket["prompt_tokens"] += prompt_tokens + model_bucket["completion_tokens"] += completion_tokens + model_bucket["calls"] += 1 + + +def get_summary() -> Dict[str, Any]: + """ + 返回当前累计的 token 用量与估算成本。 + + 格式: + { + "stages": { + "step1_ontology": { + "by_model": {"glm-4-flash": {"prompt_tokens": 1234, "completion_tokens": 567, "calls": 3, "estimated_cost_cny": 0.0002}}, + "prompt_tokens": 1234, + "completion_tokens": 567, + "calls": 3, + "estimated_cost_cny": 0.0002 + }, + ... + }, + "total": { + "prompt_tokens": 50000, + "completion_tokens": 12000, + "calls": 45, + "estimated_cost_cny": 0.0123 + } + } + """ + with _lock: + stages_out = {} + total_prompt = 0 + total_completion = 0 + total_calls = 0 + total_cost = 0.0 + + for stage_name, models in _stage_usage.items(): + stage_data = { + "by_model": {}, + "prompt_tokens": 0, + "completion_tokens": 0, + "calls": 0, + "estimated_cost_cny": 0.0, + } + for model_name, m in models.items(): + pricing = _get_pricing(model_name) + cost = ( + m["prompt_tokens"] / 1000.0 * pricing["prompt"] + + m["completion_tokens"] / 1000.0 * pricing["completion"] + ) + stage_data["by_model"][model_name] = { + "prompt_tokens": m["prompt_tokens"], + "completion_tokens": m["completion_tokens"], + "calls": m["calls"], + "estimated_cost_cny": round(cost, 6), + } + stage_data["prompt_tokens"] += m["prompt_tokens"] + stage_data["completion_tokens"] += m["completion_tokens"] + stage_data["calls"] += m["calls"] + stage_data["estimated_cost_cny"] += cost + + stage_data["estimated_cost_cny"] = round(stage_data["estimated_cost_cny"], 6) + stages_out[stage_name] = stage_data + total_prompt += stage_data["prompt_tokens"] + total_completion += stage_data["completion_tokens"] + total_calls += stage_data["calls"] + total_cost += stage_data["estimated_cost_cny"] + + return { + "stages": stages_out, + "total": { + "prompt_tokens": total_prompt, + "completion_tokens": total_completion, + "calls": total_calls, + "estimated_cost_cny": round(total_cost, 6), + }, + "current_stage": _current_stage, + } + + +def reset(stage: Optional[str] = None) -> None: + """清空统计。stage=None 全部清空,否则只清空指定 stage""" + global _stage_usage + with _lock: + if stage is None: + _stage_usage = {} + else: + _stage_usage.pop(stage, None) + + +def estimate_simulation_cost( + rounds: int, + active_agents_per_round_avg: int, + llm_calls_per_action: int = 4, + avg_prompt_tokens_per_call: int = 800, + avg_completion_tokens_per_call: int = 200, + model: str = "glm-4-flash", +) -> Dict[str, Any]: + """ + 估算 OASIS 模拟子进程的 token 消耗(无法精确测,只能估算) + + OASIS 内部每个 agent action 通常需要 3-5 次 LLM 调用: + 1. 读 timeline / 推荐过滤 + 2. 决策(要做什么动作) + 3. 生成内容 + 4. 偶尔 reflection + + Args: + rounds: 模拟轮数 + active_agents_per_round_avg: 每轮平均激活的 agent 数 + llm_calls_per_action: 每个 action 内部 LLM 调用次数(默认 4) + avg_prompt_tokens_per_call: 平均 prompt token + avg_completion_tokens_per_call: 平均 completion token + model: 模型名(用于查询定价) + + Returns: + 估算结果含 token / 成本范围(low/mid/high) + """ + total_actions = rounds * active_agents_per_round_avg + total_calls = total_actions * llm_calls_per_action + + pricing = _get_pricing(model) + + mid_prompt = total_calls * avg_prompt_tokens_per_call + mid_completion = total_calls * avg_completion_tokens_per_call + mid_cost = ( + mid_prompt / 1000.0 * pricing["prompt"] + + mid_completion / 1000.0 * pricing["completion"] + ) + + return { + "model": model, + "rounds": rounds, + "active_agents_per_round_avg": active_agents_per_round_avg, + "llm_calls_per_action": llm_calls_per_action, + "total_actions": total_actions, + "total_llm_calls": total_calls, + "estimated_prompt_tokens": mid_prompt, + "estimated_completion_tokens": mid_completion, + "estimated_cost_cny": { + "low": round(mid_cost * 0.6, 4), + "mid": round(mid_cost, 4), + "high": round(mid_cost * 1.6, 4), + }, + "note": "这是估算值,OASIS 模拟在子进程中运行,无法精确追踪。实际值可能在 low-high 区间内浮动。", + } diff --git a/backend/scripts/run_parallel_simulation.py b/backend/scripts/run_parallel_simulation.py index 2a627ffd..0eeb527f 100644 --- a/backend/scripts/run_parallel_simulation.py +++ b/backend/scripts/run_parallel_simulation.py @@ -1156,7 +1156,7 @@ async def run_twitter_simulation( agent_graph=result.agent_graph, platform=oasis.DefaultPlatformType.TWITTER, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=100, # 限制最大并发 LLM 请求数,防止 API 过载 ) await result.env.reset() @@ -1347,7 +1347,7 @@ async def run_reddit_simulation( agent_graph=result.agent_graph, platform=oasis.DefaultPlatformType.REDDIT, database_path=db_path, - semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载 + semaphore=100, # 限制最大并发 LLM 请求数,防止 API 过载 ) await result.env.reset() diff --git a/frontend/src/api/simulation.js b/frontend/src/api/simulation.js index f878586f..464985ba 100644 --- a/frontend/src/api/simulation.js +++ b/frontend/src/api/simulation.js @@ -24,6 +24,14 @@ export const getPrepareStatus = (data) => { return service.post('/api/simulation/prepare/status', data) } +/** + * 加速完成 Agent 人设生成:停止生成剩余 profile,用已生成的继续后续流程 + * @param {Object} data - { simulation_id } + */ +export const accelerateSimulationPrepare = (data) => { + return service.post('/api/simulation/prepare/accelerate', data) +} + /** * 获取模拟状态 * @param {string} simulationId @@ -143,6 +151,15 @@ export const getAgentStats = (simulationId) => { return service.get(`/api/simulation/${simulationId}/agent-stats`) } +/** + * 获取模拟完整回放数据(Manus 式过程回放) + * 一次性返回 simulation/project/workflow/config/agents/rounds/aggregate + * @param {string} simulationId + */ +export const getSimulationReplay = (simulationId) => { + return service.get(`/api/simulation/${simulationId}/replay`) +} + /** * 获取模拟动作历史 * @param {string} simulationId diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue index a27ba347..adf324cc 100644 --- a/frontend/src/components/Step2EnvSetup.vue +++ b/frontend/src/components/Step2EnvSetup.vue @@ -49,6 +49,16 @@ {{ $t('step2.generateAgentPersona') }}
+ {{ $t('common.completed') }} {{ prepareProgress }}% {{ $t('common.pending') }} @@ -639,7 +649,8 @@ import { getPrepareStatus, getSimulationProfilesRealtime, getSimulationConfig, - getSimulationConfigRealtime + getSimulationConfigRealtime, + accelerateSimulationPrepare } from '../api/simulation' const { t } = useI18n() @@ -665,6 +676,7 @@ const expectedTotal = ref(null) const simulationConfig = ref(null) const selectedProfile = ref(null) const showProfilesDetail = ref(true) +const accelerateRequested = ref(false) // 日志去重:记录上一次输出的关键信息 let lastLoggedMessage = '' @@ -824,6 +836,26 @@ const startPrepareSimulation = async () => { } } +const handleAccelerate = async () => { + if (accelerateRequested.value || !props.simulationId) return + if (profiles.value.length === 0) { + addLog(t('log.accelerateNoProfiles')) + return + } + accelerateRequested.value = true + addLog(t('log.accelerateRequested', { count: profiles.value.length })) + try { + const res = await accelerateSimulationPrepare({ simulation_id: props.simulationId }) + if (!res.success) { + accelerateRequested.value = false + addLog(t('log.accelerateFailed', { error: res.error || t('common.unknownError') })) + } + } catch (err) { + accelerateRequested.value = false + addLog(t('log.accelerateFailed', { error: err.message })) + } +} + const startPolling = () => { pollTimer = setInterval(pollPrepareStatus, 2000) } @@ -1161,6 +1193,38 @@ onUnmounted(() => { .badge.pending { background: #F5F5F5; color: #999; } .badge.accent { background: #E3F2FD; color: #1565C0; } +.step-status { + display: flex; + align-items: center; + gap: 8px; +} + +.accelerate-btn { + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + padding: 5px 12px; + border-radius: 4px; + border: 1px solid #FF5722; + background: #FFF; + color: #FF5722; + cursor: pointer; + transition: all 0.18s ease; + white-space: nowrap; +} +.accelerate-btn:hover:not(:disabled) { + background: #FF5722; + color: #FFF; +} +.accelerate-btn:disabled, +.accelerate-btn.is-requested { + cursor: not-allowed; + border-color: #BDBDBD; + color: #9E9E9E; + background: #F5F5F5; +} + .card-content { /* No extra padding - uses step-card's padding */ } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 62d23201..8614cb11 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -3,6 +3,7 @@ import Home from '../views/Home.vue' import Process from '../views/MainView.vue' import SimulationView from '../views/SimulationView.vue' import SimulationRunView from '../views/SimulationRunView.vue' +import SimulationReplayView from '../views/SimulationReplayView.vue' import ReportView from '../views/ReportView.vue' import InteractionView from '../views/InteractionView.vue' @@ -30,6 +31,12 @@ const routes = [ component: SimulationRunView, props: true }, + { + path: '/simulation/:simulationId/replay', + name: 'SimulationReplay', + component: SimulationReplayView, + props: true + }, { path: '/report/:reportId', name: 'Report', diff --git a/frontend/src/views/SimulationReplayView.vue b/frontend/src/views/SimulationReplayView.vue new file mode 100644 index 00000000..489b694a --- /dev/null +++ b/frontend/src/views/SimulationReplayView.vue @@ -0,0 +1,901 @@ + + + + + diff --git a/locales/en.json b/locales/en.json index faad45bc..ee74c4fb 100644 --- a/locales/en.json +++ b/locales/en.json @@ -103,6 +103,9 @@ "asyncTaskDone": "Async task completed", "generateAgentPersona": "Generate Agent Personas", "generateAgentPersonaDesc": "Combine context to auto-extract entities and relations from the knowledge graph, initialize simulated individuals, and assign unique behaviors and memories based on reality seeds", + "accelerateComplete": "Skip & Continue", + "acceleratePending": "Finishing…", + "accelerateHint": "Stop generating remaining personas and proceed to the next step with the ones already generated", "currentAgentCount": "Current Agents", "expectedAgentTotal": "Expected Total Agents", "relatedTopicsCount": "Reality Seed Related Topics", @@ -453,6 +456,9 @@ "preparingScripts": "Preparing Scripts" }, "log": { + "accelerateRequested": "⚡ Skip requested: proceeding to next step with {count} generated Agent personas", + "accelerateNoProfiles": "⚠ No personas generated yet, cannot skip", + "accelerateFailed": "Skip request failed: {error}", "preparingGoBack": "Preparing to return to Step 2, closing simulation...", "closingSimEnv": "Closing simulation environment...", "simEnvClosed": "✓ Simulation environment closed", diff --git a/locales/zh.json b/locales/zh.json index 5a873bdb..8c5ddc26 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -103,6 +103,9 @@ "asyncTaskDone": "异步任务已完成", "generateAgentPersona": "生成 Agent 人设", "generateAgentPersonaDesc": "结合上下文,自动调用工具从知识图谱梳理实体与关系,初始化模拟个体,并基于现实种子赋予他们独特的行为与记忆", + "accelerateComplete": "加速完成", + "acceleratePending": "正在加速…", + "accelerateHint": "停止继续生成剩余人设,使用已生成的人设直接进入下一步", "currentAgentCount": "当前Agent数", "expectedAgentTotal": "预期Agent总数", "relatedTopicsCount": "现实种子当前关联话题数", @@ -453,6 +456,9 @@ "preparingScripts": "准备模拟脚本" }, "log": { + "accelerateRequested": "⚡ 已请求加速完成:将使用已生成的 {count} 个 Agent 人设进入下一步", + "accelerateNoProfiles": "⚠ 暂无已生成的人设,无法加速完成", + "accelerateFailed": "加速完成请求失败: {error}", "preparingGoBack": "准备返回 Step 2,正在关闭模拟...", "closingSimEnv": "正在关闭模拟环境...", "simEnvClosed": "✓ 模拟环境已关闭",