72 KiB
设计文档
本地运行的个人任务 agent,覆盖三类工作:汇报 PPT、科研申报书、代码。 模型自由(LiteLLM 接 OpenAI-compatible),代码可控(目标 1500-2000 行 Python)。
1. 边界
做:PPT(python-pptx)/ 申报书(python-docx)/ 编码(读写文件 + shell + 迭代验证)。
不做:子 agent / IM 渠道 / 自定义 RAG / 锁定 Anthropic / Eval Suite(个人工具 dogfooding 替代)。多用户 / Web UI 归 §7。
关键约束:
- 模型自由:LiteLLM + OpenAI-compatible(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级不需要大改架构
- 形态兼容:本地与 SaaS 共享同一份 core / storage(PG)/ web
/v1API,无 CLI REPL / 本地 in-process 分叉(2026-05-18 撤,详 §7.9)
2. 架构
zcbot/
├── core/
│ ├── capabilities.py # ModelCapabilities,从 yaml 加载
│ ├── llm.py # LiteLLM 封装,按 capabilities 自动启 features
│ ├── loop.py # ReAct 主循环 + cancel_check 协作式 cancel
│ ├── probe.py # 真实探测对账 yaml 声称的能力
│ ├── session.py # 消息列表 + meta + 落 PG
│ ├── skills.py # SkillRegistry(Anthropic 渐进披露)
│ ├── task.py # TaskState
│ ├── memory.py # per-user .memory/ 双层记忆
│ ├── paths.py # task_dir db form 归一(to_db_path / from_db_path)
│ ├── storage/{engine,models,utils}.py # SQLAlchemy 2.x ORM
│ └── agent_builder.py # 装配 lib:build_agent / system prompt / validate_task_name
├── tools/
│ ├── base.py # Tool 基类 + _resolve
│ ├── fs.py # read / write / edit (唯一匹配) / glob / grep
│ ├── shell.py # subprocess + 黑名单
│ ├── run_python.py # tmp .py + subprocess + 敏感 env 过滤
│ ├── skill_tool.py # load_skill
│ └── skill_authoring.py # save_skill / fork_skill(host-side 写用户 .skills)
├── skills/{coding,ppt,proposal}/ # SKILL.md + references / scripts / assets
├── prompts/system/general_v1.md
├── config/{agent.yaml, models/*.yaml}
├── workspace/users/<user_id>/
│ ├── .memory/{core.md, extended/*.md} # 跨 task 共享记忆,dotfile 隔离
│ └── <working_dir>/ # 工作目录,用户起名(同 working_dir 多 task 共享)
├── web/{app.py, auth.py, broker.py, sinks.py, static/dev.html}
├── db/migrations/ # alembic
└── main.py # 入口:web / db / probe / user 四子命令
工作目录(working_dir) = workspace/users/<user_id>/<working_dir>/,所有 skill 产物写到这里,绝对路径在 system prompt 显式给 agent。写错位置(cwd / skills/ / repo 根)git status 立刻报红。user_id 走 JWT sub:邮箱密码登录由 users 表 email lookup 直读,platform 登录由 platform 直传;无 SENTINEL fallback,所有路径必须显式有 user_id。name(任务显示名)必填,working_dir 可选(留空 → 用 name 作目录名);两者都是简单名(不含 /\..、不以 . 起头,挡 .memory);同 working_dir 多 task 自动共享同目录(§7.1)。SaaS 化只是把 workspace/ 换 <storage_root>/,布局不变。
启动:python main.py web → uvicorn 起 FastAPI → lifespan 跑 stale-run reaper → 客户端走 /v1/auth/login_password(邮箱密码)或 /v1/auth/login(platform_key)换 JWT → POST /v1/tasks/{id}/messages 起 BG 线程,内部 build_agent 读 agent.yaml → 加载 ModelCapabilities → LLM(caps) → 拼 system prompt(general_v1.md + skill discovery + cwd + working_dir 绝对路径)→ 装配工具 → AgentLoop.run。ZCBOT_DB_URL 指 PG。
3. 核心组件
3.1 主循环(core/loop.py)
ReAct:LLM → 若有 tool_calls 就执行 → 结果塞回消息 → 再调 LLM。无 tool_call 即返回。
- 工具结果对模型截 16K 字符,用户预览 400 字符
- 事件通过
sink.emit流式发布(§7 A,SSE 桥);content delta 在 stream chunk 到达即时 emittext事件,前端打字机渲染 - LLM 调用走
LLM.chat_stream(litellmstream=True):chunks 攒齐后用litellm.stream_chunk_builder拼回完整 response 给 tool_calls 解析 + usage 记账;stream_options.include_usage=True让最后一个 chunk 带 usage cancel_check: Optional[Callable[[], bool]]协作式 cancel,每轮 LLM 前 + stream chunk 之间 + tool_calls 之间 poll;chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」(几十秒)降到「单 chunk 间隔」(~100ms);中途 cancel 时已收 chunk 丢弃,assistant 半截内容不入库(resume 上下文干净);命中给未执行 tool_call 补[cancelled by user]保 LiteLLM 协议- 停机判据 = 解耦「跑了几步」与「是否在推进」(2026-06-10):用户感知的"轮"是来回对话次数,一个 run 内模型自主连调 N 次 tool 概念上仍是 1 轮,该放它跑完;真正要掐的是"空转"。故
max_iterations(从 capabilities/yaml 读,flash 120 / pro 150)降级为纯安全 backstop,不再当"轮预算"砍正经长任务;主防护是两道进展信号:①_RepeatGuard逐指纹"同名同参+无产出([Error]/结果一字不差重复)"累计,SOFT=2 注提示、HARD=4 拦截;② run 级全局_stall——整步所有 tool 都无净产出则 +1、任一净产出清零,连续_STALL_LIMIT=8步主动停([stopped: no progress]),比撞 backstop 早得多掐死循环。撞 backstop / 空转停都 emit 明确"回复『继续』可续跑"提示,不静默停。取舍:step-count 是"不收敛"的粗糙代理,正经任务 80 步和死循环 5 步被一刀切同等对待是错的;进展信号才对症。新增成本=run loop 一个计数器,死循环兜底反而更早(8 步 vs 120 步)。
3.2 Model Profile(core/capabilities.py + config/models/*.yaml)
每模型一份 yaml,agent 行为按档案动态调整。新模型 5 分钟接入,不改代码。
字段:max/reliable_context、max_output、parallel_tools、tool_calling_quality、thinking_mode、reasoning_effort_levels、code_quality、enable_run_python、max_iterations、optimal_temperature、prompt_caching、extended_thinking、api_base、api_key_env。
LLM.chat 按 capabilities 自动启 parallel_tool_calls / reasoning_effort / Anthropic prompt-caching header。
3.3 Capability Probing(core/probe.py + main.py probe)
yaml 是手填的,probe 用真实调用对账:basic_chat / parallel_tools / thinking_mode / long_context(opt-in)。不改 yaml,只出 rich Table 报告。显式触发,不进启动路径(避免烧 API)。
3.4 工具系统(Hybrid 范式)
JSON tool call(tools/):read / write / edit / glob / grep / shell / run_python / load_skill / save_skill / fork_skill — 离散操作。
Code execution(run_python):tmp .py + subprocess + 工作目录限制 + 敏感 env 过滤(*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY)— 批处理 / 算数据 / 生成文档。
关键设计:edit 唯一匹配(CoreCoder 风格,old_str 重复即报错);工具按原子操作切分,不做 make_pptx() 这种高级封装。
3.5 Skill 系统(Anthropic 渐进披露)
对齐 Anthropic 2025-12 开放标准。三层加载:Discovery(name + description,几百 token)→ Activation(load_skill(name) 加载完整 SKILL.md,1-5K)→ Execution(SKILL.md 指 references/xxx 按需拉)。
原则:写 WHY+WHAT,不写 Step 1/2/3。description 决定模型能否触发。
用户私有 skill(多来源 registry,2026-06-11):SkillRegistry 收有序来源列表——内置 ROOT/skills(只读)+ 用户 user_root/.skills(可写,per-user)。用户来源排后,同名覆盖内置(user wins);覆盖在 discovery 显式标注,不静默。取舍:① user wins 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② 创作走 host-side typed tool(save_skill/fork_skill)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 user_root/.skills,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 fork_skill copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 load_errors 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
3.6 Session 与 Task
Session(core/session.py)= 消息列表 + meta,直接 ORM 写 PG messages 表(append-only,jsonb 存 LiteLLM 原样 payload)。
Task(core/task.py)= Session 上层,含 name / working_dir / skill / description / status / model / reasoning_effort / 时间戳 / tokens。直接 ORM 写 PG tasks 表。working_dir FS 目录只存 skill 产物,无 state.json / messages.json。本地 + SaaS 同一份 schema 和 ORM,差别只在 ZCBOT_DB_URL。
字段三件套语义:
name(NOT NULL) = 任务显示名,UI 列表 / 标题 / docx 导出文件名用;独立于工作目录working_dir= 工作目录(相对 ROOT posix 串),同 working_dir 多 task 共享同物理目录skill= 智能体类型标签(coding / ppt / proposal / ...自由形式,后续可对齐skills/注册表强校验)
创建语义 — working_dir 目录在 task 创建入口立即 mkdir(parents=True, exist_ok=True)(name 必填代表"显式声明项目";working_dir 留空 → fallback 用 name 作目录名)。Task 行在 web POST /v1/tasks 时即写。Task 切换 / 软删 / 硬删 走 dev SPA + /v1/tasks*(DELETE /v1/tasks/{id} 删 DB 行 + messages CASCADE;FS 一律不动,同 name 多 task 共享,绝不 rmtree)。原 CLI REPL(chat / tasks / export)2026-05-18 撤,详 §7.9。
原子性 — PG INSERT 天然原子;skill 产物走 core.session.atomic_write_text(tmp + fsync + replace)。
3.7 双层记忆(core/memory.py)
跨 task 共享的事实(用户偏好 / 项目约定 / 模型 quirk)放 workspace/users/<user_id>/.memory/(per-user,dotfile 隔离):
| 层 | 文件 | 加载 | 适合 |
|---|---|---|---|
| Core | core.md |
每次 build_agent 进 system prompt | 跨任务高频精炼事实(几百 token) |
| Extended | extended/*.md |
索引(frontmatter description,缺则退回首行标题 — legacy 兼容)+ 可写绝对路径进 prompt,内容靠 read 工具按需拉 |
大量低频专题 |
system prompt 每次 build_agent 重建(resume 也是),memory 演化即时生效。
写入路径 = agent 自管(prompt 契约,非后台蒸馏):memory_block 把 .memory/ 的可写绝对路径锚点 + 一段「记忆维护契约」一起注进 prompt(契约 + 锚点常驻,即使记忆为空,否则新用户冷启动不知道自己能记)。契约规定:学到跨 task 复用的稳定事实就当场用已有 write/edit 存,写前 grep/read 查重(更新而非堆重复),extended 一事一文件 + frontmatter description(这行进索引决定召回)。不引专用 remember 工具(复用 fs 工具,改动最小);不做后台自动蒸馏(不烧额外 token,人仍可审核/手编)。路径锚点按 backend 给 host 绝对路径 / docker /workspace/.memory(同 working_dir 的容器路径转译)。
memory 永远在 FS,不入 DB:本地 workspace/users/<user_id>/.memory/,SaaS <storage_root>/users/<user_id>/.memory/(bind mount 进容器)。dotfile .memory/ 命名避免项目名取 memory 时撞;validate_task_name 拒 . 起头双向防呆。理由:用户笔记语义,FS 读写 + 编辑器手编是产品的一部分;跨 task 共享靠"同一 user 同一目录"自动达成,无需 schema。
前端记忆面板 = 只读窗口,"改"全走对话(取舍):web 左栏「记忆」按钮开只读 modal,直接读 FS 渲染全貌(GET /v1/memory 全貌 + GET /v1/memory/extended/{filename} 单篇),故意不提供写/删 API。理由:① "看全貌"是读、不是 operation —— 走 LLM 反而又贵又只能拿到转述,看地面真相必须直读 FS;② "改"走对话(agent 自管,上文契约)= 单一写入口、自然语言、能合并改写,且用户不会写坏 frontmatter。对照业界:Claude(同为文件式记忆)给全套 view+edit;ChatGPT/Gemini 黑箱式只给看/删、长期不支持内联编辑。我们取"GUI 当眼睛、模型当手":既守住文件式记忆的透明卖点,又不引第二套写代码。后续若"删一条 / prune 臃肿 core.md"这类确定性精确操作摩擦明显,再单加直接的 delete(delete 是唯一廉价且确定性强、值得直连的 mutation,同 ChatGPT 做法)。路径穿越校验收口在 core/memory.py(只许 .memory/extended/ 下扁平 .md + resolve 子树兜底)。
4. 模型路由
默认 default_model: deepseek_v4.flash。分模式路由思路:
| 模式 | 模型 | 理由 |
|---|---|---|
| 通用 / 编码 / PPT / 提案初稿 | flash | SWE-Bench 80.6,够用 |
| 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出 |
| fallback | claude_4_7.opus | V4 不行时手动切 |
成本量级:
| 任务 | flash | pro-max | Opus 4.7 |
|---|---|---|---|
| 修 bug(~10 轮) | $0.01 | $0.05 | $0.30 |
| 5 页 PPT | $0.05 | $0.20 | $1.50 |
| 完整申报书 | $0.30 | $1.50 | $10-15 |
99% 任务 flash 够用,关键终稿升 Pro。
5. 设计哲学
核心原则:Less Scaffolding, More Trust
老 agent 框架失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。正确做法:把 LLM 当一个会持续变强的同事,告诉它目标,不告诉它步骤。
七条具体原则
- Prompt 用 WHY+WHAT 不用 HOW — 教"怎么思考"会降智强模型
- Skill 渐进披露,不写完整流程
- 工具按原子操作切分,不做高级封装 — 留组合空间
- Model Profile 化,不硬编码
- Capability Probing 对账实际行为
- 版本化 Prompt(等真要切版本时再做)
eval 评估— 已删,dogfooding 更有效
借鉴
| 来源 | 借鉴 |
|---|---|
| CoreCoder | 主循环简洁实现 + Edit 唯一匹配 |
| Anthropic Skills | SKILL.md 渐进披露 |
| nanobot | Workspace + 任务隔离 |
| smolagents | LiteLLM + CodeAct 启发 run_python |
6. 风险与取舍
| 风险 | 缓解 |
|---|---|
| run_python sandbox 不够强(本地非真隔离) | 工作目录限制 + 敏感 env 过滤;SaaS 走 docker exec(§7.5);本地依赖用户最终审阅 |
| V4 某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
| Skill description 不准 → 触发不到 | Pro 优化描述,实战观察 |
| Long context 退化 | probe --long-context 探测可靠 ceiling |
| 本地 PG 离线 | docker compose up -d 起本地 PG 兜底;也可连远端 dev / staging PG |
Hybrid 范式而非纯 CodeAgent:V4 JSON tool call 已稳定;sandbox 成本只在需要时付;兼容 thinking。 Anthropic Skill 标准:行业标准已成,跨 SDK 兼容。 不做 subagent:状态管理爆炸;单 agent + skill 已覆盖 95% 场景。 不做 Eval Suite:个人单用户场景,dogfooding 信号比造作 case 强,probe 覆盖健康检查。
7. SaaS 化(草案,status=design,2026-05-12)
§1-§6 是本地 dogfood 形态;本节是SaaS 形态,把 core 包成多用户在线服务。 不引入 platform/core 切分 — core 就是后端,直接对用户做 auth。两条形态共享同一份 core,差别只在反代部署。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
7.0 与本地形态的兼容性
SaaS 化不是"重写",而是把同一份 web /v1 服务部署到云端。
| 维度 | 本地 | SaaS |
|---|---|---|
| 入口 | python main.py web 起 FastAPI + dev SPA |
uvicorn 部署形态,反代到 platform UI |
| Storage | PG(ZCBOT_DB_URL 指 docker compose / 远端 dev PG) |
PG(指生产 PG) |
| working_dir | workspace/users/<user_id>/<name>/ |
<storage_root>/users/<user_id>/<name>/ |
| Memory | workspace/users/<user_id>/.memory/ (FS, dotfile) |
<storage_root>/users/<user_id>/.memory/ |
| Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec |
| Auth | 邮箱密码(users.email/password_hash,bcrypt)→ JWT;platform_key → JWT(机器对机器) |
OIDC → JWT(D' 替换 platform_key 路径);邮箱密码长期保留,与 OIDC 并存 |
workspace/ 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 users/<user_id>/ 子树布局,差别只在外层根目录,不在 storage 形态。
7.1 心智模型:Task 一等公民 + Dir 文件副视图
两个并列入口,正交不嵌套:
| 视图 | 入口语义 | 适用场景 | API |
|---|---|---|---|
| Task list(主) | "我的对话历史" | 任务驱动:"继续昨天那个 bug fix" | GET /v1/tasks?status=&working_dir= |
| Dir tree(辅) | "我的文件资产" | 项目驱动:"看汇报项目里所有素材 + 关联对话" | GET /v1/folders + GET /v1/files |
类比:macOS Finder + 最近使用 / Apple Notes 文件夹视图 + 全部备忘录。两个视图查同一份数据的不同切面,dir 不是 task 的父容器。
- Task = DB 一行,一等公民,自带
working_dir字段:- 新建必给
name(简单名),working_dir = workspace/users/<user_id>/<name>/(留空 fallback 用 name)。同 working_dir 多 task 共享 → "同一项目多对话"语义 - 指定 → 项目化 task,同 working_dir 多 task 自动共享
source//sections// 终稿(无需建"项目"实体)
- 新建必给
- Dir = FS 路径,无 DB 实体,path 即标识;无父子结构,改名走顶层目录 DB-aware 同事务 cascade(§7.4)
- No-subtask:同 working_dir 允许(同项目多对话),前缀嵌套拒
- Messages = DB 表,append-only,
jsonb存 LiteLLM 原样 payload - Skill 产物全落 working_dir,不引入 artifacts 表;SKILL.md 指示 agent 清中间件
- Skill 定义是项目代码,跟部署走,所有用户共享
空 dir(用户上传素材但还没开 task)在 dir tree 视图正常展示 — 上传本身是有效产品行为;UI 上跟"有 task 的 dir"做轻量区分(如 task 数 badge)。
state / messages 两形态都在 PG,FS 只承担 skill 产物。多 task 共享同 working_dir 时由 §7.8 文件级悲观锁兜底。
7.2 资源模型(/v1)
Task 一等公民;files 与 task 正交(§7.1),走 user-rooted /v1/files*,以 workspace/users/<uid>/ 为边界(不强制选 task)。所有路由统一 /v1 前缀,返 JSON;前端由 platform 端实现(§7.9 取舍),本地开发用 FastAPI /docs Swagger UI 自查。
Tasks
POST /v1/tasks {name(必填), working_dir?, description?, skill?};不合法 → 400
GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=
分页 1-based;page_size 1–100 clamp;ordering DRF 风格逗号分隔,
`-field` 倒序;allowlist created_at/updated_at/name/status;
**默认 `-created_at`**;返 `{page, page_size, count, results}`
GET /v1/tasks/{id} 单 task meta
PATCH /v1/tasks/{id} {status?,description?,name?,skill?};active 不让从 web 切回
DELETE /v1/tasks/{id} 硬删:DB 行 + messages CASCADE;**FS working_dir 保留**
GET /v1/folders 列当前 user 的 working_dir + 关联 task 计数 + 最后使用时间
GET /v1/tasks/{id}/messages 历史(后续 ?search= 走 jsonb GIN / tsvector)
POST /v1/tasks/{id}/messages {content} 发消息 + 起 run,返 {events_url}
**单活 run**(0004 简化):tasks.run_status in
('running','cancelling') → 409;`SELECT … FOR UPDATE`
锁 task 行序列化并发 POST 防 messages.idx race
GET /v1/tasks/{id}/events SSE 流(见下) — 订阅 task 当前活动事件,
单活 run 形态下无歧义,客户端只需 task_id
POST /v1/tasks/{id}/cancel 协作式 cancel(202):标 cancelling + 信号 broker;
BG loop 在 stream chunk 间 + 工具调用之间 poll 看见即退;
run_status != running → 409;cancel 延迟 ~ 单 chunk 间隔(100ms 级)
Auth
POST /v1/auth/login {user_id, platform_key} → JWT(platform 机器对机器)
POST /v1/auth/login_password {email, password} → JWT(dev SPA / 同事试用)
bcrypt 校验 users.password_hash(0005 加 UNIQUE(email));
错邮箱 / 错密码 / 未设密码统一 403 防探测
POST /v1/auth/change_password {old_password, new_password} → {ok}(dev SPA 顶栏自助改密)
需 Bearer(user_id 取自 JWT);验旧密码 + 新密码 ≥6 bcrypt 重哈希;
旧密码错 / platform_key 建的无密码行 → 403,弱密码 → 400
Files(user-rooted,workspace/users/<uid>/ 为根)
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};
留空 → user_root;dotfile(`.memory/` 等)一律隐藏
POST /v1/files/upload multipart;path 通过 form;严格拒含 / \\ .. 的 filename
GET /v1/files/download?path= 下载单文件;`..` / 绝对 / symlink 越界 400
POST /v1/files/delete {path} 文件或空目录;非空目录 400;user_root 拒;
**path 是顶层目录(user_root 直接子项)且被 task 引用 → 409**
POST /v1/files/rename {path, new_name};sibling 已存在 → 409;
**path 是顶层目录** → 同事务 SELECT FOR UPDATE 锁关联 task +
任一 running/cancelling → 409 + check_no_subtask 防嵌套;
DB UPDATE 在 FS rename 之前,FS 失败回滚 DB
Export
GET /v1/tasks/{id}/export docx 临时文件下载,BackgroundTask 删 tmp
Misc
GET /healthz {"status":"ok"}
GET / 302 → /static/dev.html(本地 dev SPA)
SSE 事件(Content-Type: text/event-stream,响应头带 X-Accel-Buffering: no 给 nginx 反代友好;每事件 event: <type> + data: <JSON>):
run_start {}
llm_start {}
text {"delta":"<delta 文本>"}
tool_call {"name":"...","args":{...},"args_preview":"..."}
tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览
llm_end {"prompt_tokens":N,"completion_tokens":N}
cancelled {} # cancel 命中,后随 done 收流
error {"msg":"<type>: <detail>"}
done {}
订阅 fan-out:同 run 多订阅者(刷新 / 多 tab / 多设备)每订阅 1 独立 queue。订阅迟到(run 已 done)立刻收 done 不挂。事件不持久化 — messages 走 PG,未来要"刷新继续看流式"再加 event log。
版本化:/v1 minor 半年向后兼容,major 6 个月 deprecation。
CORS:本地 dev allow_origins=["*"];部署 platform 时收紧。
Auth:Bearer JWT 走所有 /v1/tasks*;/healthz、/docs、/openapi.json、/、/v1/auth/login*、/static/* 豁免。
7.3 认证
当前形态(D' 过渡):两条 login 路径签同款 JWT(HS256,JWT_SECRET env 签,默 7d TTL):
POST /v1/auth/login {user_id, platform_key}— platform 服务端机器对机器入口,持PLATFORM_KEY共享密钥可为任意 user_id 签 token(等同 user 身份由 platform 注入)POST /v1/auth/login_password {email, password}— dev SPA / 同事试用,users.emailUNIQUE + bcrypt 校验password_hash;main.py user addCLI 发用户POST /v1/auth/change_password {old_password, new_password}— dev SPA 顶栏自助改密,需 Bearer(user_id 从 JWT 取,不信前端);验旧密码 + bcrypt 重哈希;platform_key 入口建的无密码行不可改(403)GET /v1/me— 返{user_id, role}(role 走 DB 查),dev SPA 据此决定显不显"管理"入口GET /v1/admin/*— 管理后台,Depends(require_admin)(验 JWT +users.role=='admin',否则 403)。/v1/admin/overview返固定指标(runtime/tasks/users/usage 总用量+近7d趋势,供轮询);/v1/admin/usage/models?range=&sort=、/v1/admin/usage/users?range=&sort=&page=&page_size=、/v1/admin/storage/users?page=&page_size=是带时间筛选(all/7d/30d)/ 排序(cost/tokens)/ 分页的独立表端点。独立页/static/admin.html(目录导航 + 客户端打印导出 PDF)。后续续挂建用户/改角色/配置等管理动作
后续 Authorization: Bearer <jwt> 走所有 /v1/tasks*,FastAPI Depends(require_user) 验签 → 提取 user_id → SELECT/UPDATE 全带 Task.user_id == user_id 条件做隔离。/v1/admin/* 在 require_user 基础上再叠一层 users.role=='admin' 检查(make_require_admin)。PLATFORM_KEY / JWT_SECRET 任一缺失 → app 启动 fail-fast。
信任模型:platform 是单点可信中间层(持 PLATFORM_KEY = 可为任意 user_id 签 token),风险与"platform 服务端泄漏 = 用户身份泄漏"同级,可接受。
未来形态(真 OIDC):Provider 签 ID token,zcbot /v1/auth/login 内部从"校验 PLATFORM_KEY"换成"校验 ID token 签名 + 提取 sub" — 路由层 Depends 不动,Bearer JWT 契约不变。邮箱密码路径长期保留,与 OIDC 并存(自有账号体系 + 同事试用不依赖外部 IdP);OIDC 只接管 platform 机器对机器那条路径。所有 storage/executor scoped by user_id,无 tenant 层 — 个人 SaaS 用不上,做企业版再加 org_id 等价隔离。
7.4 存储:Postgres + 本地文件系统
users(user_id uuid pk, email text null unique, password_hash text null, oidc_subject null, plan null,
role text not null default 'user', -- 0009:user/admin;admin 才能访问 /v1/admin/* 管理后台
created_at)
-- email UNIQUE (0005);NULL 不冲突,允许 platform_key 入口 user 共存
-- 入口三条:① main.py user add(bcrypt → password_hash;dev SPA 邮箱密码登录用)
-- ② /v1/auth/login platform_key 路径 ensure_user_row(只填 user_id)
-- ③ 未来 OIDC(替换 login 内部;email/oidc_subject 由 ID token 注入)
-- role:make_require_admin 每请求查(不进 JWT,改完即时生效、老 token 不重签);
-- 提管理员 main.py user role --email X --role admin。与 ZCBOT_ADMIN_TOKEN
-- (发用户共享口令)正交,互不相干
tasks(task_id uuid pk, user_id fk, name text not null, working_dir text not null, skill, description,
status, model_profile, tokens_prompt, tokens_completion, cost_usd,
run_status text not null default 'idle', -- idle/running/cancelling/error(0004 合 runs 表)
run_error text null,
created_at, updated_at);
create index on tasks (user_id, working_dir);
-- working_dir 存储:相对 ROOT 的 posix 串(workspace/users/<uid>/<name>);写入入口
-- 只接 simple name,越出 ROOT → to_db_path raise(不留 ROOT 外路径)
-- 读写边界统一过 core/paths.py::{to_db_path, from_db_path}
-- 入口校验 validate_task_name():拒空 / 含 /\NUL / `.` 起头 / >255
messages(message_id uuid pk, task_id fk, idx int not null,
payload jsonb not null, tokens_in, tokens_out,
model_profile text null, -- 0006:只在 assistant 行有值,标产生该 msg 的模型
created_at,
unique (task_id, idx));
create index on messages using gin (payload jsonb_path_ops);
usage_events(event_id uuid pk, user_id fk, task_id fk on delete cascade,
message_id fk on delete set null,
kind text not null, -- chat / image / video / audio / ...(0006 起只 chat,媒体扩展位)
model_profile text not null,
units jsonb not null, -- chat: {tokens_in, tokens_out};image: {count, size};...
cost_usd numeric(12,6) not null default 0,
created_at);
create index on usage_events (user_id, created_at); -- 用户级聚合走这条,JOIN-free
create index on usage_events (task_id);
create index on usage_events (model_profile, created_at);
0004 简化:runs 表角色等价"task 当前 in-flight 状态",合并到 tasks.run_status + run_error;run_id 单活 run 形态下对客户端 / broker / cancel 全冗余 → 客户端只需 task_id。
0006 模型切换 + 用量统计:tasks.model_profile 从 0001 起就有,本次开始真用 —— task 创建时 UI 选 / PATCH 切;build_agent resume 读它而非 cfg["default_model"](A 粒度:下条 send 才生效,当前 run 不受影响)。messages.model_profile 新增,assistant 行落实际用的模型,前端按 model 切换点画小标。usage_events 表 0004 删掉的简陋版形态(id/user_id/task_id/run_id/kind/value/ts)字段不够多态,本次重建 v2 形态:per-event 一行,units JSONB 装多态用量(token / 张数 / 秒数),cost_usd 用 litellm cost map 算;chat 已接入(core/loop.py 在 assistant message 入库后调 record_chat_usage),媒体工具未来加 image/video kind 不动 schema。tasks.tokens_prompt/completion/cost_usd 三列保留作粗 task 级概览,继续由 sync_task_tokens 维护;messages.tokens_in/out 同时双写,查 message 详情不需 JOIN。统计真实 source-of-truth 走 usage_events,跨用户 / 跨模型 / 跨时间维度都按 (user_id, created_at) 索引直查。
run_status 终态语义:ok / cancelled 收尾回 idle(用户视角等价),只有 error 持久(让用户能看到),起新 run 时由 post_message 清。
No-subtask 校验(create_task):同 user 下查 new LIKE existing/% 或 existing LIKE new/%,中一则拒;同 working_dir 允许。两侧先用 from_db_path 归一到 absolute posix 再比前缀(混合存储形态不漏判),数量小直接 Python 端比对,不在 SQL 里拼分隔符。
Folder rename / delete(/v1/files/rename + /v1/files/delete):files API 是目录树唯一 mutation 入口,DB-FS 一致性作服务端不变量内化(§7.9 架构教训)。顶层目录(user_root 直接子项)走 DB-aware 分支:事务内 SELECT ... FOR UPDATE 锁关联 task + 任一 running/cancelling → 409 + check_no_subtask(exclude=被改名 tids) 防嵌套;rename UPDATE DB 在 FS rename 之前(FS 失败可回滚);delete 顶层目录有任意 task 引用 → 409 要求先 DELETE 关联 task。
文件系统(本地 <storage_root> = workspace/,SaaS 替换为部署根):
<storage_root>/users/<user_id>/
.memory/{core.md, extended/} # per-user 记忆,dotfile 隔离,不入 DB
<name>/ # 项目目录,name 用户起(必填),working_dir 直接落这
# 同 name 多 task 共享同目录(§7.1)
Storage 实现:单一 PG ORM(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。
7.5 沙盒:Per-user 容器 + Per-tool exec
| 选择 | 理由 |
|---|---|
| 每 user 长驻 sandbox container | 文件模型本来以 <storage_root>/users/<user_id>/ 为安全边界;同 user 多 task / working_dir 共享素材与中间产物,per-user 容器比 per-task 容器更贴合心智模型 |
每 tool 调用一次 docker exec |
exec 级 timeout / cwd / 资源统计;一轮对话内多次 tool call 复用同一容器 |
| 空闲 5 分钟回收 | 对话结束后进入 idle;5 分钟内新对话 / 新 tool call 复用,否则销毁;不浪费也避免频繁 cold start |
| bind mount = user root | <storage_root>/users/<user_id>/ → /workspace;每次 exec 显式 cwd=/workspace/<working_dir>;同 user 内不做运行时隔离,跨 user 由独立容器 + 独立 mount 隔离 |
边界划分:Control plane 留在宿主后端,Execution plane 进容器。宿主后端负责 auth / JWT / DB 事务 / task-message 状态机 / /v1/files 路径校验与上传下载 / SSE broker / LLM 调用 / 受控 web_search 与 web_fetch / 配额审计。容器只跑不可信执行: shell、run_python、用户或模型生成脚本、编译器 / 解释器 / 包管理器 / 渲染命令。目标不是"所有操作都进容器",而是"所有不可信代码执行都不能在宿主执行";否则 DB 凭据、JWT secret、对象存储凭据反而更容易被带进执行面。
硬限制:cgroup CPU/mem、pids-limit、单次 exec timeout、同 user 并发 exec 数、上传大小、root fs read-only、tmpfs /tmp、no-new-privileges、drop ALL caps、非 root 用户运行。run_python 临时文件落 /tmp/zcbot/<task_id>/ 或 working_dir 受控临时目录;exec 结束后按进程组清理,避免后台进程常驻。
软配额:按 user 计入 DB,超额返回 429 / 402 / 明确错误。配额项包括 workspace 磁盘总量、月度 LLM cost / tokens、tool wall time、文件上传下载流量、网络下载量、running task / exec 并发数。磁盘配额起步用应用层统计(上传 / write / tool 执行前后检查 + 周期扫 user root),后续需要更硬边界再上 filesystem project quota / volume driver。
网络:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 curl 外网 / 内网 / cloud metadata。
选型:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。
落地清单(Stage C 实施硬协议,与 PROGRESS Stage C DoD 锚定;实施时按此对账):
-
网络 blocklist 硬编码段(容器 iptables 启动必含,任一缺失=Stage C 未完成):
169.254.0.0/16(cloud metadata SSRF)、127.0.0.0/8+::1(loopback)、内网三段10/8+172.16/12+192.168/16、100.64.0.0/10(CGNAT);PG 实际 IP 单独再 block 一遍(belt-and-suspenders,自定义网络/VPC peering 会让段级 block 看似覆盖实际能直连)。 -
网络 egress 模型:容器内
HTTP(S)_PROXY走宿主侧 proxy + iptablesDROP outbound except <proxy port>(防 SDK 不读 env 绕过)。宿主 proxy 负责:① 域名 allowlist;② 红线段 IP block(再做一次);③ per-user 出网字节计量入软配额(超额 429);④ 审计日志network_audit。Allowlist 初始集:*.pypi.org/*.pythonhosted.org/github.com/raw|codeload|objects.githubusercontent.com/*.npmjs.org+ 部署的 PyPI 镜像域名。 -
进程组清理协议:
docker exec通过setsid包一层,timeout/cancel/正常结束三路径都kill -- -PGID杀整组。目的:防nohup &/disown/派生 daemon 跨 exec 持久化——同 user 不做内隔离,stale 进程能看到后续 exec in-memory 状态,守不住这条残留风险就放大成"跨对话持久后门"。 -
磁盘配额硬化时点:首版应用层统计 + 周期扫描(=软配额);外部用户开放前必须升级到 xfs/ext4 project quota 或 zfs dataset quota。否则扫描间隙打满共享 fs 会拖死同节点其他 user(写满远快于扫描周期),且不算配额超额、排查痛苦。
-
Executor 接口 + runtime config 注入:不在工具层 hard-code
docker exec,走 backend driver 抽象(Executor.call_tool(tool, args, ctx));container runtime 走 configZCBOT_SANDBOX_RUNTIME=runc|runsc(docker run --runtime=)。理由:未来切 gVisor/Firecracker/Kata/e2b 应用层零改动,避免接口泄漏 Docker 假设(docker exec/cp/stats)致后期重写。 -
工具按信任域二分,Executor 内部 dispatch(2026-05-26 修正:原"host 工具走
resolve_user_path校验"是假命题无此函数;dogfood 发现 glob 仍列 host repo,改物理边界替代代码护栏):- Container exec backend:
shell/run_python/read/write/edit/glob/grep全走 docker exec。shell/run_python 是任意代码;fs 工具以前 host 跑base_dir=Path.cwd()无 user_root 校验能读/etc/passwd/源码/~/.ssh,进容器后user_root=/workspace是物理边界。调用形态:docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>+ stdin 喂 JSON args(CJK/引号透明传);tool_runner.py复用tools/fs.py,skill references 走skills:/sandbox/skills:romount。 - Host in-process backend:
load_skill/save_skill/fork_skill/web_*/seedream/seedance/document_*/mp_*— 持 key 不能进容器 env;load_skill是内存查找无越界;save_skill/fork_skillhost-side 写user_root/.skills(沙箱 fs 的 base_dir 够不到)。 - Dispatcher(
DockerExecutor)内部分流,AgentLoop零感知;接口形状按"未来全进容器 + tool-runner unix socket RPC"留好(升级信号见下表)。代价:每 fs tool call 多 ~200ms,对话级 N≤15 → 1-3s,LLM 推理 5-30s 下噪声。
- Container exec backend:
-
Secret-bearing domain tools 不进 sandbox,不做 key 下发(2026-06-01):凡需
*_API_KEY/OAuth/DB credential 的能力不能让容器读 env,也不做"credential broker 发短期 key"(sandbox 内任意代码可print(os.environ)/monkeypatch SDK,短期 token 只缩有效期不改根因)。正确形态=host-side JSON tool:LLM 传非敏感业务参数 → host tool 取 key 调远端 API → 裁剪/限大小/计量/审计 → 只返业务结果或落盘文件路径,容器最多读到落盘产物。已落地:documents/Materials Project 改 host tool(详 PROGRESS 06-01)。注册规则:仅对应 env 存在时注册,否则 schema 不暴露 + skill 文档提示降级。
升级触发信号(反向兜底:无信号不升级):
| 升级方向 | 触发信号 | 不升级的理由 |
|---|---|---|
Docker → gVisor(runsc) |
开放陌生注册 / 逃逸 CVE 未及时打补丁窗口 / 可疑 syscall 告警 | Docker + 完整 hardening 已挡主流逃逸,kernel 0day 在 dogfood 阶段非 #1 风险;gVisor syscall -30~50% 是真代价 |
| gVisor → Firecracker / e2b | 合规客户(PCI/HIPAA) / 单机 100+ user / gVisor 兼容墙撞死 | Firecracker 每 VM 100MB+ 起步不划算;e2b 数据出去执行与 storage_root 自持模型冲突 |
docker exec → 容器内 tool-runner(socket RPC) |
docker_exec_overhead/total > 30% 持续两周 / 模型起长驻 web 服务 / 单轮工具调用 >20 次 |
自管进程组清理 + cgroup + 状态污染面 + 失去 Docker 工具链观测,代价 >> 200ms×N;美学统一性 ≠ 升级理由 |
Image 体积 / 多 user 资源 / 加包策略(2026-05-28):sandbox image ~1.5G(python+chromium+node+mermaid),后续 domain 包还会推大。三点认知分开:
- Image 大 ≠ 运行时吃更多资源:空载
sleep infinityRSS 个位数 MB,image 里的库不 exec 只是磁盘字节;layer 共享让 N 个 user 容器磁盘乘数=1。真吃 RAM 的是 active exec(chromium 渲 mermaid 瞬时 200-500MB),跟 image 大小解耦。 - 多 user 瓶颈在并发 exec 不在 idle 容器:100 idle 容器几百 MB 可接受;10 user 同渲 mermaid 瞬时 2-5GB 才是瓶颈。杠杆全在运行时(单容器
--memory/--cpus/--pids-limit+ 同 user exec semaphore + 整机 active cap + idle 5min 回收),减 image 体积对这条曲线无影响。 - 新增依赖:base 收敛 + per-user 持久化 venv + 使用频次沉淀:重包(torch/texlive)或长尾 domain 包不进 base,中高频+轻量的留 base;采用 per-user venv 落
<user_root>/.venv/(bind mount 进容器 idle 回收不丢,pip install --target+PYTHONPATH注入)。不放共享 named volume(破坏跨 user 隔离,install 脚本是任意代码);不依赖 pip cache(只省网络、回收照丢)。沉淀机制:audit 统计 >30% user 装过 ≥3 次的包 → 下次 build 合并进requirements.txt,base 跟真实使用收敛。
落地对应 Stage C DockerExecutor(cgroup limits / 并发 semaphore / idle 回收 / per-user venv);audit 沉淀可延后。
7.6 Core 代码改造(按依赖顺序)
哪步做完见 PROGRESS
## 状态;此处只记改造项与依赖顺序。
| # | 项 |
|---|---|
| 1 | 事件流化 loop.py |
| 2 | Storage 落 PG(Session/TaskState 改 SQLAlchemy + alembic + docker-compose) |
| 3 | working_dir 字段语义(name 必填,派生 users/<uid>/<name>/,同 name 共享) |
| 4 | Files API(list/upload/download/delete/rename,user-rooted) |
| 5 | No-subtask 校验 |
| 6 | Executor + sandbox(run_python/shell → Executor.run;docker exec) |
| 7 | HTTP /v1 surface |
| 8 | |
| 9 |
代码量增量:+1000~1500 行(单一 PG 比双 adapter 省 500-800 行;UI 不计入)。
7.7 分阶段落地
阶段做没做完见 PROGRESS
## 状态;此处记阶段范围 + 设计意图(估时 / 撤销 / 前置依赖)。
| 阶段 | 范围 | 设计意图 |
|---|---|---|
| A | 事件流化 | — |
| B | Storage 落 PG + working_dir 语义 + no-subtask | 一次性切换,无双轨(见下) |
| D | HTTP /v1 surface | — |
| D' 过渡 | 邮箱密码 + PLATFORM_KEY → JWT + user_id 隔离 + dev SPA | — |
| CORS 收紧 | allow_origins 从 * 改 platform 域名 allowlist |
已接入真实用户,应尽快做(与 OIDC 解耦) |
| D' 真 OIDC | 替换 /v1/auth/login 内部为 ID token 校验(邮箱密码并存保留) | 选做,platform_key 信任模型可接受则可延后;真要弃 PLATFORM_KEY 共享密钥时再做 |
| C | Executor + sandbox(run_python/shell → Executor.run;docker exec) |
3 天,外部用户开放的 hard prereq(详 §7.8 / §7.9 2026-05-21) |
| 撤(§7.9) | ||
| 撤(§7.9) | ||
| F | 上线打磨(限流 / 监控 / 告警 / HA) | 持续 |
B 阶段一次性切换:切到 PG 后本地与 SaaS 走相同代码路径,无回退、无双轨,dogfood 即生效。 D 落在 G 前面:原排期 D 在 G 后(以为 dogfood 用 UI 跑),转向"platform 端联调"后 API surface 反而成阻塞;G 的 Jinja2+HTMX 投入沉淀的 sink 协议 / broker / no-subtask / files 路径安全归一 / task_dir 相对存储仍被 D 复用。
7.8 已知风险
| 风险 | 缓解 |
|---|---|
| 过早抽象违背 §5 | B 阶段单一 PG 无 adapter;各阶段独立 dogfood 价值;CLI REPL 整套撤无双 transport 维护税 |
/v1 冻死后演化慢 |
minor 半年兼容,major 6 个月 deprecation |
| Rename 误中前缀 / 漏改子 task | cascade SQL 用 old/% + 单测覆盖 |
| Running task 被 rename / delete | 后端校验 + UI 禁按钮(详 §7.4) |
| 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin |
| DB-then-FS 中断留孤儿目录 | rename 顺序 DB UPDATE → FS rename(FS 失败回滚 DB);delete 后台 GC 周期扫"FS 有但 DB 无引用" |
| 同 folder 多 task 并发写同名 | known limitation,实践频率近 0(同 wd 多 task 是"项目对话历史轨迹",非并发);dev SPA chat 区顶 banner 软警告(GET /v1/tasks?working_dir=&run_status=running,cancelling 拉同 wd 活跃邻居),不挡发送;宪法文件已由 <date>-<short_id> 命名隔离(§7.9 2026-05-20);真高频出现再加 gate |
同 task 并发 POST messages 撞 messages.idx |
POST /messages 单活 run gate:SELECT … FOR UPDATE 锁 task + run_status in ('running','cancelling') → 409;启动 lifespan reaper 把孤儿 running/cancelling 全标 error。未来 multi-worker 换 heartbeat / lease |
| Run 跑太久 / 用户想中断 | POST /v1/tasks/{id}/cancel 协作式;LLM 走 streaming,chunk 间 poll cancel → 延迟 ~ 单 chunk 间隔(100ms 级) |
shell / run_python 在 SaaS 无沙箱即开放外部用户 = 主机沦陷 / 跨 user 读 working_dir / cloud metadata 凭据泄漏 / 内网扫描 |
Stage C(§7.5 per-user sandbox container + per-tool exec + drop caps + read-only rootfs + bind mount = own user root + default-deny network)是开放外部用户的 hard prereq;现状 tools/shell.py::BLOCKED_PATTERNS 是 trivial-bypass 的装饰品(双空格 / bash -c / python -c / curl | sh / cd / 全能过),不在它上面加规则,黑名单 fundamentally broken;外部开放前仅 dogfood + 信任同事白名单手动加 |
| Sandbox 出站越权 | 容器默认 deny outbound;搜索 / 抓网页走宿主受控工具;依赖安装走受控 PyPI 镜像或 HTTP proxy,并显式阻断 cloud metadata / 内网地址 |
| 资源滥用 | 容器硬限制(CPU/mem/pids/timeout/并发 exec/上传大小) + user 软配额(磁盘、LLM cost、tool wall time、文件流量、网络下载量);idle 5 分钟回收 |
7.9 取舍说明
path-as-identity 而非 folder_id:folder 真实存在于 FS,folder_id 等于造两份 source of truth。rename 是 UI 主动动作,顶层目录走 DB-aware 同事务 cascade(§7.4)。
/v1/files/* 作目录树唯一 mutation 入口,DB-FS 一致性服务端内化(2026-05-18):此前提过双命名空间 /v1/folders/rename vs /v1/files/rename,内部 if path is top-level 分支被视为"代码异味"。实际反了 — 这个分支从数据状态派生(path 恰好是 working_dir),不是从客户端意图派生,放服务端是更安全的位置(client 没法绕过去导致悬空引用);双命名空间反而把同一个分支搬到 client 去做,失去强制力且端点表面翻倍。
user auth 而非 tenant 层:个人 SaaS 用不上。企业版加 org_id claim 等价。
skill 产物全落 working_dir 不引入 artifacts 表:中间件是用户花 token 生成的资产,可下载可替换;artifacts 表是为不确定 UX 收益预付架构成本。真嫌乱 UI 加折叠视图。
hard cascade 而非 soft orphan → task 改软删除(2026-06-17 推翻):原决策为避免 orphaned 特殊 case 选硬删(DELETE tasks CASCADE 连带 messages/usage_events)。公测后目标变为沉淀用户对话轨迹做训练/研究语料,硬删 = 语料永久丢失,故推翻:DELETE /v1/tasks/{id} 改为置 tasks.deleted_at(0010 migration),从 list_tasks / list_folders 计数中过滤,messages/usage_events(CASCADE 不再触发)与工作目录文件全部保留;新增 POST /v1/tasks/{id}/restore 恢复。原"特殊 case"成本被一处 WHERE deleted_at IS NULL 收口(列表是唯一用户可见入口,按 id 取单 task 的端点不过滤,恢复/直链仍可达)。心智改为:平台对数据 append-only,用户"删除" = 可见性状态,永不销毁字节。物理清理留给将来的管理员工具。delete_file 顶层目录 409 引用检查同步排除软删 task(否则"任务都删了文件夹却删不掉"死结)。
文件留存(归档)—— 设计已定,实现待办(2026-06-17):任务对话靠软删除即留在 DB;但用户文件在 FS 上,删除/覆盖即字节丢失,需单独留存以供训练/研究。已对齐的方案(尚未实现,优先级靠后):① 基础设施层定时增量备份做持久化地基(restic/borg → 只进不删、内容寻址去重,定时跑;与应用代码完全解耦 → 新端点/新工具自动覆盖不会漏,且捕获删除+覆盖+最终成品,这是"删除前归档"钩子拿不到的)+ ② 应用层轻量事件日志(删除/覆盖时只追加 user/task/path/time/reason 一条,补 ① 缺的用户意图/出处语义;放 DB 表 data_events 而非 jsonl,避并发追加竞争)。起步同盘(防误删+留语料够;不防整盘损坏 —— 已知边界,将来换备份 target 到第二块盘/异地即可,纯配置改动)。不选"每个删除端点内联 copytree-再删":横切关注点手写 N 处 → 易漏(删文件/夹/skill、rename/upload/i2i 覆盖入口持续增加)、只看得见删除一瞬、跨卷拷脆。覆盖(如 seedream i2i 改图)若 ① 颗粒度不够,将来在该具体工具内定点补"覆盖前快照",不铺全局钩子。
0004 删 runs + usage_events 表(2026-05-18):runs 表 tokens_p/c 写但从未读(真 tokens 走 tasks 累计),started_at/finished_at/error 也只写不读;run_id 单活 run 形态下对客户端 / broker / cancel 全冗余。合并 run_status + run_error 两列入 tasks。usage_events 从未真写,纯死代码,真要计费再加。代价:失"历史 run 元数据"(每次 LLM 调用的独立时间戳 / token 切片) — messages 表已记下产物,token 累计在 tasks,真要细粒度审计再补回 usage_events(届时是新需求,不是技术债)。
本地也用 PG,不用 SQLite / JSON:① dogfood ≡ 真实用户路径,bug 在 dogfood 就能复现;② Docker 已是必然依赖(§7.5),docker compose up postgres 零增量门槛;③ 双 adapter 维护税远高于 PG 一次性配置成本;④ 本地 dev 也能连远端测试服。
API-only,UI 由 platform 实现(2026-05-15):用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML/CSS/HTMX 就是双套 UI 浪费。删 web/templates/* + Jinja2/markdown-it-py/pygments 依赖,SSE event payload 从 HTML 片段切 JSON,路由统一 /v1 前缀。沉淀:sink 协议 / RunBroker fan-out / no-subtask / files 路径安全归一 / task_dir 相对存储全部保留,不被 UI 层牵连。
dev SPA 留一份 + 升级为本地 dogfood 主路径(2026-05-15,05-18 强化):web/static/dev.html 单文件 vanilla JS,3 栏布局(task list + chat + files),无构建链。与"UI 由 platform 实现"不冲突 — platform UI 是给真用户的生产形态;dev.html 是给开发者 dogfood + 自验 /v1 API + SSE 流的开发期工具(SSE 调试在 curl 里看不到 UI 反应,Swagger 不发 SSE 流也没流式视图,删了再补不如留着)。登录页两 tab(邮箱密码 默认 / UUID+PLATFORM_KEY 备用,last-used 持久化)→ JWT → localStorage → fetch+Bearer。
CLI REPL 撤,入口统一 main.py {web,db,probe,user}(2026-05-18):原计划 cli.py chat REPL 本地直跑 + --remote https://... 走 HTTP,两套覆盖"本地调内部状态"+ "dogfood ≡ 真用户路径"。dev SPA 落地后浏览器一直开着,REPL 命令与 web /v1 接口完全等价;维护双套 task 切换语义只是"对称美",每个 REPL 命令的 bug fix 要在 web 端再 fix 一次。--remote 从未实现也再不需要(platform 联调 + dev SPA + curl Swagger 已覆盖)。失:CLI "无 auth 直跑调 core 内部状态"通道 — 但 dev SPA 邮箱密码登录走同一条 web 路径,看内部状态可临时写几行 ad-hoc script,不需要常驻 CLI 命令。
Memory 不入 DB:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。
Tasks/Messages 在 PG 但 skill 产物在 FS:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(*.pptx / *.docx / sections/*.md)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。
同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告(2026-05-21):dogfood 经验同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0。走 Claude Code 同款"信任 + 软警告 + 承认 limitation",dev SPA 在 selectTask + SSE 收尾拉同 wd 活跃邻居挂 banner,不挡发送,真高频再升级。不选 γ(同 wd 单活 gate):硬挡破坏扁平共享中间产物的切换流畅性;不选 short_id 全产物隔离:破坏 §7.1 共享语义 + SKILL 改造成本;不选 clone task:对零频"真要并行"场景工程量过重。
shell / run_python 不在工具层加强黑名单,§7.5 sandbox 是 SaaS hard prereq(2026-05-21,05-25):BLOCKED_PATTERNS 只挡几个明显失误,稍有意识就绕过(双空格 / bash -c / python -c / curl|sh / cd / 全过),cwd 非 chroot。不继续加规则:命令注入图灵完备(shell=True + 任何脚本语言),黑名单枚举不完、越复杂越虚假安全感、误伤合法用法。正确防线在 OS 层:§7.5 per-user 容器 + drop ALL caps + read-only rootfs + bind mount own root + default-deny network + cgroup,Stage C 前仅 dogfood + 信任白名单。per-user 非 per-task:文件模型 user-rooted,安全目标是跨 user 隔离非同 user task 互隔。非所有操作进容器:auth/DB/files/SSE/LLM/受控 web 工具属 control plane 留宿主做权限审计,只有不可信代码进 execution plane。本地 dogfood 接受风险:自己机器 + 自己 prompt,blast radius 限自身(§5);外部场景 blast radius 是主机 + 他人数据 + cloud IAM,信任模型不同必须 §7.5。
task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录(2026-05-20):同 wd 多 task 共享中间产物(source/sections/figures)是价值,但 spec 这种 1:1 宪法文件必须隔离。文件名 <YYYY-MM-DD>-<task_short_id>-<task_name>.<base>.md:short_id(task_id.hex[:8] 永不变)主锚,glob 字典序最大=current;日期让"重定调"写新文件成历史快照;task_name 仅可读说明,改 name 不 cascade(short_id 兜底)。不选:① cascade rename(in-flight 丢文件 + 复杂);② DB 化(最干净但工作量 5-10× 且失"直接编辑 markdown"、spec 字段还在演化);③ 物理 task 子目录(破坏扁平共享)。升级 DB 化信号:想做结构化编辑视图 / 跨 task 查 spec 字段 / 版本文件堆积乱。约定由 _build_system_prompt 单点注入,所有 skill SKILL.md 引用同一份。
8. 未来步骤 / 已落地设计
实施细节(步骤清单 / 验收项)进 PROGRESS + git;此处只留缺口、选型与取舍。
8.1 图像理解 + Seedream i2i(2026-05-29 设计;✅ 2026-06-16 i2i + look_at_image 双双落地)
缺口:DeepSeek V4 主模型纯文本无视觉;seedream 只 t2i;"基于已生成图二次修改" / "上传外部参考图让 agent 据此干活"两条路径未覆盖。
选 E + C 组合:seedream 加 reference_images 走 i2i(改已生成图,像素级)+ 新增 look_at_image 走豆包视觉单图理解(读外部图,DeepSeek 自决何时调)。改动面=2 tool + 1 prompt 段 + 1 yaml 段,不动 loop / llm / capabilities / DB / 前端。
模型选型更新(2026-06-16):设计时写的 Seed 1.6 vision 已过时,落地用 Doubao Seed 2.0 Lite(
doubao-seed-2-0-lite-260428,2026-02 发布、05 升级为全模态理解 SOTA 细粒度感知)。Seed 2.0 全系文本模型已原生支持图片输入 → A 路(主模型换多模态)门槛降低,但主模型 DeepSeek V4 的 code/tool-calling 仍是核心,维持 C 路(vision 当工具)不变。token 计费(输入 ¥0.6 / 输出 ¥3.6 / Mtok),一次读图 < ¥0.01。
- 不选 A(主模型换多模态):V4 的 code / tool calling 是主路径核心,换豆包当主 chat 降能力 + 要改 loop/memory 引 multimodal,工程 5× 且破坏架构。
- 不选 B(后台 vision 路由):每条消息隐式 vision 描述 = 多烧 token + 1 跳延迟 + 失去 agentic 控制权 + debug 难。
关键实测:Seedream 5.0 /images/generations 接受 image_urls base64 data URL,200 返新图 → 内网无需对象存储中介(排除最大工程不确定性)。约束:输出 ≥~1920²、单张参考 ≤10MB、最多 14 张。
风险 / 边界:v1 只支持单张参考(multi-ref 角色定义靠 prompt,留 v2);base64 ARK 未承诺长期稳定(收紧则降级走 TOS 上传换 URL)。
落地实况(2026-06-16,详 PROGRESS):
- E 路(改图):
seedream加reference_images(v1 单图,传 >1 报错);路径解析强制落 user_root 内防越界;前端chat.js补 paste 路径注入(把粘贴图路径作[用户上传的参考图]行进正文,修了"粘贴路径到不了模型"的既有缺口)。 - C 路(看图):
look_at_imagetool(tools/look_at_image.py)走 Seed 2.0 Lite/chat/completions,base64 单图 + 问题 → 文本解读;doubao.yaml加vision:段;usage.py加record_vision_usage(kind="vision",token 计费);agent_builder 注册 + media prompt 段。图片解析与 i2i 共用tools/image_ref.py。真机 smoke(scripts/smoke_look_at_image.py)OCR 验证通过。 - 两路均不动 loop / llm / DB schema(
usage_events.kind自由文本,vision 无需 migration)。 升级到 A 的信号:用户要"贴图同时说话模型直接读图回话",或多轮带图成高频 —— 当前假设"图是工具调用对象"而非"对话内容"。
8.2 Token 优化与上下文治理(2026-06-04,✅ 已落地,详 PROGRESS)
根因:Session.load() 把全量历史装回每轮 LLM 调用,旧 tool 结果 / load_skill 正文 / 检索结果 / 长 stdout 反复携带;LiteLLM cost map 未覆盖 V4 致 cost_cny=0 不可用。
质量边界(设计约束,后续改动都守):
- 不改模型输入的优化(prompt caching、固定前缀、计费修复、cache hit/miss 记录)不影响输出质量。
- 改模型可见上下文的优化(裁剪 / 摘要 / 按需读取)必须保留可追溯原文:长结果写文件留路径,summary 只替代陈旧噪声,用户确认过的需求 / 规格 / 大纲 / 关键结论不删。
- 禁止把"只保留最近 N 条"当主策略 —— 省 token 但最易丢已确认约束。
选型:Context Editing + Memory/File State + Cache Observability 混合。稳定 system/tools 前缀利于 provider cache;旧 tool result 移除或压缩;关键发现写 task summary / FS,需要时 read 重新拉。长上下文保留作少数全局推理的临时能力,非默认每轮成本。
落地形态:core/context.py 发送前压缩旧 tool / load_skill / assistant tool_call arguments(保 role/tool_call_id/name 协议完整),不改持久化历史;上下文压力门槛(2026-06-10):总 chars 未逼近上限则完全跳过压缩、原样发,护 DeepSeek 前缀缓存(短任务字节逐轮一致、命中 92-94%)。task summary(旧消息压成一条、区分硬约束/计划/文件路径/关键事实)为第二步,未做。
8.3 PPTX 前端在线预览(2026-06-09,✅ 已落地 Stage 1)
动机:文件区点 .pptx 原只能下载;要在浏览器直接翻看,且覆盖任意 pptx(含上传)。
关键洞察(定方案极简):前端已有 <iframe src=blob:application/pdf> PDF 原生渲染路径,所以后端把 pptx 转 PDF 即可,前端几乎不动(不需 pdf.js / PNG 栅格化)。
选型 LibreOffice→PDF:像素级保真 + 通用(任意 pptx)+ 前端复用现成 PDF iframe;代价=服务器装 LibreOffice + CJK 字体。劣选:轻量 HTML(复杂 pptx 失真,不满足"任意");LibreOffice→PDF→PNG(多栅格化层、失矢量缩放、无收益)。
与 scripts/pptx_preview.py 分工:后者是 agent 生成阶段自检(pptx→Chrome→PNG,近似但零服务器依赖);本方案是面向用户的高保真预览。
落地形态:web/pptx_render.py(soffice 转 PDF,独立临时 UserInstallation 绕单 profile 锁 + 缓存 .preview/<hash>.pdf + 超时 kill)+ GET /v1/files/preview_pdf(复用 _safe_path 防穿越 + per-path asyncio.Lock + run_in_executor)。转换在 web host 不进沙盒(沙盒不该有 LibreOffice;预览与 deck 生成解耦)。
安全边界:对上传任意 pptx 跑 LibreOffice(历史有宏/EPS CVE)→ --convert-to 默认不执行宏 + 宏安全 high + 禁网 + 仅处理鉴权用户自己 user_root 内文件。
保真边界:deck 用微软雅黑,Linux 上替换成 Noto Sans CJK 度量略差(可接受)。Stage 2(未做):常驻 soffice listener 消冷启、deck 生成后 eager 预转、缩略图导航。
8.4 运维监控 / 无感更新(2026-06-11,监控 ✅ 已落地 / 无感换版 status=design)
背景:已上生产、真实用户在用。换版可用性从"nice to have"变真账;且当前并发到多少、线程池有没有排队没有观测手段。
心智
- 优雅 drain(已实现,2026-06-10) —— SIGTERM 后拒新 run(503)、等在跑的 run 收尾再换版,不再标
error。这是单实例能做到的上限。剩余代价:几十秒 503 窗(dev SPA 退避重试已吸收)+ 换版时 SSE 重连丢正在吐的 delta。 - 真正先撞的瓶颈是线程池,不是别处:run 走
asyncio.to_thread(web/app.py:1382)用默认ThreadPoolExecutor(min(32, cpu+4)),每个活跃对话整个 run 期占 1 线程。4 核 ≈ 8 并发活跃对话就排队,第 9 个 SSE 卡着不吐 token。解这个只需调大 executor / 加信号量背压,不引外部依赖。
落地排序(便宜→贵,到触发线才进下一级)
- 轻量监控(✅ 已落地 2026-06-11,详 PROGRESS):核心数据现成 ——
len(app.state.inflight)=当前活跃 run 数(含排队)、broker._subs=SSE 订阅者、resource.getrusage=RSS(Unix,Windows 跳过)。周期日志优先(lifespan 起 task 每 60s 打[stats] active_runs=N max=M rss=X),因为要的是历史峰值不是此刻快照;/v1/stats端点(复用ZCBOT_ADMIN_TOKEN鉴权)为辅。前提:启动时显式建 executor +set_default_executor接管,才能读max_workers且日后可调大。 - 按数据决策:
active_runs峰值不逼近线程池 → 并发非瓶颈,扩容彻底搁置;逼近 → 先调大 executor(改个数字),再观察。 - 503 窗优化(零依赖):
--reload(RUN.md §A)把窗从几十秒缩到 <1s。
不做监控界面(现在):运维健康(线程池/内存/SSE/容器)是少数标量,日志 + 偶尔 curl 够诊断,可视化是过度工程;业务分析(token/任务/成本)已落 DB(usage_events/tasks 三列),SQL 查即可。界面阶梯:日志 → /v1/stats JSON → (要趋势图)Prometheus+Grafana(不自写前端)→ (要给非技术人看报表)只读 dashboard。现在停在第一级。
搁置(成本不抵当前收益):gunicorn 无感换版 / broker 外置 Redis / nginx 蓝绿双实例 —— 留到"单机线程池调到头仍不够"或"换版断流成真实投诉"再议(无感换版需先把 broker 外置共享,分析见 RUN.md §B)。
8.5 定时任务 / 计划运行(Scheduled Jobs)(2026-06-18 设计,status=design)
缺口:无任何定时触发机制。但有价值的活很多是时间驱动而非事件驱动 —— 每日简报、每周综述、定时拉数据存盘、早安提醒。当前必须有人在对话里手动发消息才跑得起来。诉求:用对话方式创建"每天 X 点干 Y"的任务,到点自动跑、结果送达。
业界印证(四源高度收敛):OpenClaw cron-jobs / Autobot(agent-loop×cron)/ Claude Code routines / geta.team 自建调度器,关键模式一致 —— ① cron 到点往同一条 agent 主管线注入一条带标记的消息,不另起执行路径;② 三种会话隔离模式(isolated 默认 / persistent 续上下文 / main 系统事件);③ isolated 运行到期自动 prune;④ 退避重试(transient vs permanent);⑤ per-job 超时;⑥ 投递显式 + runner 兜底(OpenClaw --announce);⑦ 5 段 cron + 时区,警惕 dom/dow 同列的 vixie OR 语义坑;⑧ 持久化用 DB,管理三件套(cron_create/list/delete);⑨ 对话式自然语言创建即标准做法。
核心洞察(把方案收口到极简):定时任务本体 = 什么时候(cron+时区) + 做什么(一句自然语言 prompt) + 跑在哪(会话模式)。复用现成 agent 主管线(web/app.py:_run_agent_bg,§3.6 / §7.2 同一条 POST /messages 路径),守护循环只负责"到点把一条带 [定时任务] 标记的 prompt 喂进去",不造第二套跑 agent 的逻辑。
关键解耦:"发邮件"不是一等公民,是 agent 据 prompt 调工具的一个动作。 job 模型只存 prompt,"做什么 / 结果发哪"全在那句话里(发邮件→调
send_email;出简报→load_skill落盘;打招呼→回一句话)。好处:未来加任何能力(telegram / webhook / 落盘 / 调 API)不改 schema,只要 agent 有对应工具、prompt 说清楚。
三层投递(没人盯着看 → 结果不能丢):
- baseline(永远有,零配置):定时 run 就是正常 run,结果必进对应 task 线程;守护循环跑完给该 task 打未读/通知标记,用户下次登录可见。
- opt-in 推送(prompt 驱动):要发邮件/(将来)telegram → prompt 里说,agent 调工具发。灵活、能写动态正文。
- 可靠兜底(可选结构化
notify):某 job 要"必达某邮箱、不靠 AI 记性" → job 带notify={channel,to},守护循环 run 完确定性补发最新产物。不填走第 1 层。
会话模式(隔离轴,业界核心设计点):
- isolated(默认):每次触发新建临时 task,只带 job 的 prompt + skill,不继承对话历史。上下文最小 → 省 token(契合 high-turn 烧 token 治理,§8.2 / project_high_turn_token_burn_root_causes);临时 task 打标签 + 到期自动归档,防 task 列表被每日任务刷屏。
- persistent(可选):job 绑定一个常驻 task(
bound_task_id),每次往同一线程追加消息,有跨天连续性("和昨天比")。代价:线程越长重发历史越多、token 逐日涨 —— 仅在用户明确要连续性时用。
数据模型(新表 scheduled_jobs,独立加表不碰现有 schema → 公测兼容):
id, user_id, name, prompt, cron, tz(默 Asia/Shanghai), mode(isolated|persistent), bound_task_id(可空), notify(JSONB 可空), enabled, timeout_seconds, next_run_at, last_run_at, last_status, last_error, last_task_id, consecutive_failures, expires_at(可空), created_at, deleted_at。Alembic 加表 migration;usage_events 复用现成记账(可加 kind="scheduled" 自由文本区分,无需 migration)。
守护循环(仿 §8.4 _disk_scanner,plain-asyncio):lifespan 起一个后台 task,每 ~10s(ZCBOT_SCHEDULER_TICK_SECONDS,只决定最坏延迟≤1tick、不决定会否漏 —— claim 取 next_run<=now 的全部)扫 enabled AND next_run_at<=now();命中即 asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...)) 复用现成路径,登记到 app.state.inflight(随关停 drain 一起收尾)。与单活 run 锁(§7.x run_status + SELECT FOR UPDATE)交互:isolated 每次新 task 天然无冲突;persistent 若绑定 task 正忙 → 跳过本次 + 记 warn,下一个点再来(不排队堆积)。run 完回写 last_* + croniter 算 next_run_at。
croniter 选型:存标准 5 段 cron 串 + 时区,croniter 算 next_run_at。理由:正确处理 dom/dow 同列的 vixie OR 语义和时区折算(手搓极易踩坑,四源都点名这个坑);纯 Python 小依赖。劣选:只支持"每天/每周 HH:MM"自己用 datetime 算 —— 零依赖但遇复杂周期要返工。
可靠性(业界补的,纳入设计):
- 退避重试:transient(限流/网络)指数退避重试(60s→120s→300s),成功重置;permanent(prompt 报错/鉴权)直接失败记
last_error。 - per-job 超时
timeout_seconds:超时复用现成协作式 cancel 信号(§7.x)。 - 无补跑(no catch-up):守护进程宕机期间错过的点跳到下一个,不补 N 次(同 Claude Code 语义)。
- 防自我繁殖:定时 run 内禁用
schedule_create(防任务造任务);并发调度数设上限。 - expiry 安全界:
expires_at+consecutive_failures阈值 → 连续失败 N 次或长期没人管自动停,防僵尸定时任务(同 Claude Code 7 天过期思路)。
对话端(用户要的"对话方式创建"):核心是 host-side 工具三件套 schedule_create / schedule_list / schedule_cancel(写 scheduled_jobs,按 user_id 隔离,密钥不进沙箱,沿用 §3.4 typed-tool 范式)。自然语言进、自然语言管("我有哪些定时任务""取消那个简报")—— 即 Claude Code 模式(其定时任务纯工具实现,无配套 skill,证明工具单干可跑通)。
- 工具必须、skill 可选后置:skill 是 markdown 不能落库,执行器只能是工具;收集字段/
ask_user确认这套流程,能力强的模型靠工具自描述 schema 即可走通。故 v1 纯工具(schema + 参数描述写好就够),契合 §5 "Less Scaffolding, More Trust" —— 先信模型,跑不好再加脚手架。 - skill 真正值钱处不是教填参数(schema 够),而是教写好
job.prompt:job 的 prompt 决定未来每天那次 run 的质量,用户随口一句直接存会跑得差;好 prompt 要自包含/可重复/产物位置明确/把发哪存哪写死 —— 模型默认不会,值得一份模板+确认纪律(cron 口径翻译、回读人话确认、默认 isolated 并提示 persistent 代价)去教。v2 按需补:实测发现 agent 写的job.prompt质量差 / 确认流程乱再加;且因调度低频,用按需load_skill(§3.5)而非 always-on prompt 块,避免每轮白烧 token(§8.2)。 - 三件套用三个独立工具(schema 清晰、对齐 Claude Code
CronCreate/List/Delete),非单工具带action参数。
取舍(不选):
- 不引 APScheduler / Celery:项目刻意用 plain-asyncio 后台循环(§8.4),调度需求是单机低并发,引调度框架/Redis broker 是过度工程。
- 不学 geta 用 JSON 文件持久化:已有 PG + SQLAlchemy + alembic,加表是自然选择(JSON 文件丢状态、无事务、无按 user 查询)。
- email 不做成 job 一等字段:降通用性(见核心解耦);仅留可选
notify兜底。
风险 / 边界 / 待定:
send_email工具仍要建(tools/send_email.py,host-side,仅当SMTP_*env 存在才挂,沿用 §3.4 "有 key 才注册"),让第 2/3 层能用。待定:SMTP 发信账号(企业邮箱/QQ/163/Gmail 应用密码)—— 给真实账号走 env,或先占位走沙箱验证链路。- 计费归属:定时 run 计入 job 所属
user_id的 token/配额,usage_events标kind="scheduled"可审计。 - 错峰抖动:多用户同设 8 点 → 按 job-id 加确定性偏移防同一秒打爆 LLM provider(单机低并发,列 nice-to-have 不阻塞 v1)。
- 待定小项:可选
notify字段是否 v1 就上(倾向上,零成本兜底);expires_at默认值。
改动面(v1):1 张新表 + migration、1 守护循环(lifespan)、4 个 schedule 工具(create/list/update/cancel)、1 个 send_email 工具、agent_builder 注册 + 定时 run 内工具裁剪。v2 按需:薄 skill(教写 job.prompt)。不动 loop / llm / capabilities / 现有 DB schema。
前端取舍(2026-06-18 定 + 落地):对话端做完整 CRUD,前端只读展示 + 停用/删除。 前端 SPA 调 /v1/* REST、不经 agent → "界面建/改定时任务"必须另开 REST + 表单 + cron 构建器(整套最重的是让科研用户填 cron 的 UX)。既然产品本就是对话式 agent,把建/改/删/查全收到对话(schedule_* 工具),前端退化成只读看板:GET /v1/schedules 列表 + 列表项「停用/删除」两个高频便捷动作(PATCH/DELETE /v1/schedules/{id})。好处:cron 构建器 UX 难题直接消失(用户从不在前端填 cron,对 bot 说"每天早九点"由模型翻译);无"前端改了和对话不同步"的状态问题。代价:界面不能新建/编辑(需求低频,且对话更自然)。落地:web/static/js/crons.js 只读 master-detail modal(复用 skills modal 范式)+ 左栏 rail「定时」入口;工具与 REST 共用 core.scheduler CRUD 服务层不漂移。
8.6 平台渲染层 rendering/(2026-06-23,✅ 已落地)
心智:文档渲染(md→docx/pdf)是平台能力,不是 skill 内容。 像 chromium / document_search / python 一样,skill 调用它而非各自 bundle 一份。
起因:_CHEM_RE 化学式下标白名单在 brief/paper/proposal 三份 render_docx.py 逐字重复(改一处易漏改),patent/standard 还复用 proposal 那份;且 brief 缺 PDF 路径,模型临场手搓 weasyprint + 运行时 pip(线上事故)。
为什么不放 skills/_shared/ 让各 skill import:Skills 走 Anthropic 自包含/渐进披露/可 fork bundle 标准(§3.5),fork_skill 把内置 skill 整份拷到用户 .skills。跨 skill import skills._shared 会破坏 fork(用户拷贝里 import 不到内置树)且 sys.path 脆。故抽到顶层 rendering/ 平台包,bind-mount 进 /sandbox/rendering(pool.py,与 skills 同款 :ro),与 skill bundle 正交。
结构:common.py(叶子原语单一事实源:字体 OOXML/CHEM_RE/块级正则/表格行切分/图片路径)+ docx_manuscript.py(paper 投稿稿 + proposal 申报书,配置化双 profile:页边距/TOC/图题前缀/列表模式/分页策略)+ docx_brief.py(brief 简报富渲染:商务红 + 引文上标超链 + callout,复用 common 叶子)+ pdf.py(md→HTML→沙盒 chromium --print-to-pdf,复用 common.CHEM_RE)+ render.py(统一 CLI --profile {brief,paper,proposal} --format {docx,pdf})。各 skill SKILL.md 调 python /sandbox/rendering/render.py,不再自带 render_docx.py。
PDF 用 chromium 不用 weasyprint:chromium 镜像已装(给 mermaid),fonts-noto-cjk 已装,完整浏览器内核 CSS 保真度高;weasyprint 要 pango/cairo 原生库、不在仓库 Dockerfile。与 §8.3 pptx 预览分工:pptx 预览在 web host 调 LibreOffice(面向用户的高保真预览,不进沙盒);本层在沙盒内 chromium 渲染(agent 生成阶段产出 docx/pdf 交付物)。
取舍:重构对三 profile 各渲前后 diff word/document.xml 字节一致(零回归);brief 不强并进 manuscript 路径(引文/配色差异大,只共用叶子原语,降回归面)。
附录:DeepSeek V4 关键事实(2026-04-24)
- V4-Pro:1.6T / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6
- V4-Flash:284B / 13B 激活,1M context
- 推理模式:non-thinking / thinking / thinking-max
- 价格:in ~$0.145/M,out ~$1.74/M(约 Claude Opus 1/6 ~ 1/7)
deepseek-chat/deepseek-reasoner2026-07-24 下线 → 必须迁deepseek-v4-flash/deepseek-v4-pro