65 KiB
运行手册
怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看
DESIGN.md,进度看PROGRESS.md。
最后更新:2026-06-12(admin 角色 + /static/admin.html 管理后台:user role CLI / 建用户带 --role / 顶栏"管理"入口)
环境
- Python:虚拟环境
.venv/,所有依赖装在里面。一律用.venv/Scripts/python.exe ...(Windows)/.venv/bin/python ...(Unix),不要全局python(litellm/python-pptx 等会 ModuleNotFoundError)。 - 配置文件
.env(项目根,git 忽略,litellm 自动加载):DEEPSEEK_API_KEY=sk-... # 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段) ZHIPUAI_API_KEY=... # 豆包(火山方舟)统一 key,三处共用:可选。 # 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点 # 2) 图像生成 seedream tool(0.22 元/张) # 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s) # 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现 ARK_API_KEY=... # documents skill(内部知识库 document_search API):可选。设了后注册 # document_list_kb / document_search / document_download 三个 host-side tool; # key 只留宿主后端,sandbox/run_python 不读取。 DOCUMENT_SEARCH_API_KEY=... # 可选:覆盖默认 base_url(默认 https://ai.ctc-zc.com:8100/api) # DOCUMENT_SEARCH_URL=https://ai.ctc-zc.com:8100/api # pymatgen skill 的 Materials Project 接入:可选。设了后注册 # mp_search_summary / mp_get_structure / mp_get_entries 三个 host-side tool; # 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)仍在 sandbox 跑。 # 申请 https://materialsproject.org/api(免费) MP_API_KEY=... # 本地 / 内网部署 LLM(`config/models/local.yaml`,DeepSeek-R1 满血 / QwQ-32B 原生 32K, # 共享同一台推理服务 http://182.54.21.126:9000/v1)。涉密任务用户显式选 `local.r1` / `local.qwq` # 代替默认 deepseek_v4.flash;未设 env 时这两条 variant 调用即抛 RuntimeError(其他模型不影响) LOCAL_LLM_API_KEY=... ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot # main.py web 必填(probe/db/user 不验) PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验> JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造> # 可选:覆盖默认 7d JWT TTL # ZCBOT_JWT_TTL_SECONDS=604800 # 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503) # ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令> # 可选:web run 线程池大小(asyncio.to_thread 默认池),默 min(32, cpu+4)。每个活跃 # 对话整个 run 期占 1 线程,active_runs 逼近此值即排队(看 journalctl 的 [stats] 行); # 并发不够再调大(先确认是真并发高、而非单条 run 慢)。 # ZCBOT_RUN_MAX_WORKERS=16 # 定时任务发邮件(send_email tool + 定时任务 notify 兜底投递,DESIGN §8.5):可选。 # 三者齐了才挂 send_email tool(没配的部署 agent 看不到这个工具);密钥只留宿主、不进 sandbox。 # SMTP_HOST=smtp.qq.com # SMTP_PORT=465 # 默 465;465→SSL,其余→STARTTLS(可用 SMTP_TLS=ssl|starttls|none 覆盖) # SMTP_USER=you@qq.com # SMTP_PASSWORD=<授权码/应用专用密码,非登录密码> # SMTP_FROM=you@qq.com # 可选,默认取 SMTP_USER # SMTP_FROM_NAME=总院科研辅助智能体 # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号) # 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner): # ZCBOT_DISABLE_SCHEDULER=1 # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON) # ZCBOT_SCHEDULER_TICK_SECONDS=10 # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏) # ZCBOT_SCHEDULER_CONCURRENCY=4 # 可选,并发跑的定时 run 上限,默 4 # 微信接入(ClawBot 个人微信,DESIGN §8.7):可选。开关在才挂 wechat_push tool + 起入站长轮询。 # ZCBOT_WECHAT_BOT_ENABLED=1 # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定 # ZCBOT_WECHAT_SECRET_KEY=<随机串> # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底) # ZCBOT_WECHAT_BASE_URL=... # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com) # 企业微信(渠道 B,出站推送 + 入站对话,§8.7):三件套齐才挂推送。无条件主动推,补 ClawBot 24h 窗口短板。 # WECOM_CORPID=ww... # 企业 ID(管理员:我的企业→企业信息) # WECOM_AGENTID=1000002 # 自建应用 AgentId # WECOM_SECRET=... # 自建应用 Secret # ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com # 可选,OAuth 回调主机(须在应用「企业微信授权登录」可信域名内;缺则取请求 base) # 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项, # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。 # WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成) # WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)litellm 在 import 时副作用加载 .env;入口走
main.py,.env自动生效。直跑python -c "from core.storage import ..."不经 litellm 链路时记得自己import litellm触发,或手动export ZCBOT_DB_URL=...。 - 依赖:
pip install -r requirements.txt(已在.venv里;含bcrypt、segno、cryptography)。 - 微信接入(ClawBot,§8.7):①
main.py db upgrade head带上 migration0012;②.env设ZCBOT_WECHAT_BOT_ENABLED=1+ZCBOT_WECHAT_SECRET_KEY=<串>;③ 用户登录后点左栏 rail「微信」按钮(/static/wechat_bind.html仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;主动推送需用户近 24h 在微信开口过一次(冷启动/超期推不出,退邮件兜底)。 - 企业微信(渠道 B,纯推送,§8.7):① 管理员建自建应用 → 填
WECOM_CORPID/AGENTID/SECRET(+ 可见范围含目标用户);②main.py db upgrade head。绑定两条路,任选:- 手填 userid(无域名时,最省):rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。推送是出站调用,不需要域名/HTTPS,这条最省事。
- 扫码授权登录(要 HTTPS 域名):管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设
ZCBOT_PUBLIC_BASE_URL;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调/v1/wecom/oauth/callback公开(身份从 HMAC state 验)。链接走login.work.weixin.qq.com/wwlogin/sso/login(不是网页授权oauth2/authorize,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。 - 绑定后简报/结果无条件主动推(不挑活跃度、无 24h 窗口),适合必达。
- 入站对话(可选,要公网 HTTPS):企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL
<公网 base>/v1/wecom/callback+ 自动生成的 Token / EncodingAESKey → 写进 envWECOM_CALLBACK_TOKEN/WECOM_CALLBACK_AESKEY→ 保存时企微 GET 验 URL(/v1/wecom/callbackGET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。支持文本 + 图片 + 文件(图片/文件走 media/get 下载,落盘进会话目录 inbound/);语音/视频/位置等暂不处理;未绑定/空消息静默。
- channel 长会话上下文(微信/企业微信通用,0019):常驻会话不再无限膨胀。① 自动分段——入站时距上次消息超过
config.json的channel.session_gap_hours(默 6 小时,设<=0关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② 手动新话题——用户在微信/企业微信里直接发「新话题 / 新会话 //new/ 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都不删任何消息,只移动「喂给模型的窗口起点」tasks.context_base_idx。网页端「清空对话」(POST /v1/tasks/{id}/clear)仍整清并把 base 归 0。需main.py db upgrade head带上0019。 - PG:
ZCBOT_DB_URL必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。 - Auth env:
PLATFORM_KEY+JWT_SECRET任一缺失 web 启动 fail-fast。生成随机串:python -c "import secrets; print(secrets.token_urlsafe(48))"。 - 用户管理(
users.email/password_hash/role,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLImain.py user add(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设ZCBOT_ADMIN_TOKENenv,弹窗输入 email/密码/管理员口令/角色)。撤用户DELETE FROM users WHERE email=...(先 DELETE 该 user 的 tasks)。用户自助改密:登录后顶栏「改密码」按钮(走POST /v1/auth/change_password,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 - 角色与管理后台(
users.role∈user/admin):admin 才显顶栏"管理"入口 →/static/admin.html(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员main.py user role --email X --role admin(改完即时生效,role 走 DB 查不进 JWT)。ZCBOT_ADMIN_TOKEN是另一回事(发用户共享口令),与 role 互不相干。
一次性初始化
# 1) 装依赖(若 .venv 不在)
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt
# 2) 准备 .env(见上)
# 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db current # 应输出 0010 (head)
日常命令
入口
main.py {web, db, probe, user}。所有 task / 消息 / 文件交互走main.py web起服务后浏览器(dev SPA)或 platform 端 / curl 调/v1/*。
Web 服务
# 默认 127.0.0.1:8765;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe main.py web
.venv/Scripts/python.exe main.py web --port 9000
.venv/Scripts/python.exe main.py web --reload # 文件改动自动重启
DB / probe / user
# 模型能力对账(费 token)
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
.venv/Scripts/python.exe main.py probe --model glm.pro # 智谱 GLM-5.1(走 litellm zai provider + 国内站 bigmodel.cn)
.venv/Scripts/python.exe main.py probe --model local.r1 # 内网 DeepSeek-R1(满血,128K),涉密任务用;需 .env 设 LOCAL_LLM_API_KEY
.venv/Scripts/python.exe main.py probe --model local.qwen3 # 内网 Qwen3-30B-A3B(MoE,原生 32K);共享 LOCAL_LLM_API_KEY
# DB migration
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe main.py db current
# 发用户(两条路径,任选其一)
# a) CLI:
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com role=user user_id=<uuid>
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
# 弹窗输入 email/密码/管理员口令/角色,POST /v1/auth/admin/create_user 落库。
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 角色:user(默认)/ admin。admin 才能开顶栏"管理"入口 → /static/admin.html 管理后台
# (监控总览:运行态/用量/任务/用户/存储)。建用户时带 --role,或事后改:
.venv/Scripts/python.exe main.py user add --email ops@x.com --password "s3cret" --role admin
.venv/Scripts/python.exe main.py user role --email alice@example.com --role admin
# → [ok] role set email=alice@example.com role=admin user_id=<uuid>
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com';
Auth + curl 用 token 调 /v1/*
两条 login 路径,签同款 JWT。所有 /v1/tasks* 需 Authorization: Bearer <jwt>。
# 路径 1:邮箱密码(dev SPA / 同事试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"atLeast6"}'
# 路径 2:platform_key + 指定 user_id(机器对机器)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}'
# 调 /v1/*
TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
dev SPA:打开 http://127.0.0.1:8765/(自动 302 → /static/dev.html),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:main.py user add 发用户,不用重启 web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。
iframe 嵌入(platform 主页内嵌):URL 加 ?embed=1&parent_origin=<父页面 origin>,触发 embed 模式 —— 藏左上 brand / 退出按钮,登录页不显示,新建任务挪到任务面板;父页面通过 postMessage 协议推 JWT(zcbot-ready / zcbot-token / zcbot-401)。完整对接手册见 EMBED.md(URL 参数 / 协议 / 后端 SSO 示例 / 父端前端示例 / 安全 / 故障兜底)。
路由表
全 JSON,CORS allow_origins=["*"];详细 schema 见 /docs。
| 方法 + 路径 | 用途 | Auth |
|---|---|---|
GET /healthz |
{"status":"ok","version":"<zcbot 版本>"} |
豁免 |
GET / |
302 → /static/dev.html dev SPA |
豁免 |
GET /docs /openapi.json |
Swagger UI / OpenAPI schema | 豁免 |
GET /static/* |
dev.html 等静态文件 | 豁免 |
POST /v1/auth/login |
platform 机器对机器;body {user_id, platform_key} → {token,expires_at,user_id,ttl_seconds} |
豁免 |
POST /v1/auth/login_password |
dev SPA 邮箱密码;body {email, password} → {token,...,email,...};邮箱不存在 / 密码错 / 未设密码统一 403 |
豁免 |
POST /v1/auth/change_password |
dev SPA 顶栏「改密码」;body {old_password, new_password}(user_id 取自 JWT)→ {ok:true};新密码 <6→400、旧密码错 / 无密码(platform_key 建的行)→403 |
必填 |
POST /v1/tasks |
创建 task,body {name(req), working_dir?, description?, skill?} |
必填 |
GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering= |
列任务,默认 -created_at;响应 {page, page_size, count, results};ordering DRF 风格逗号分隔 -field 倒序,allowlist created_at/updated_at/name/status |
必填 |
GET /v1/tasks/{id} |
单 task meta + n_messages;跨 user → 404 |
必填 |
PATCH /v1/tasks/{id} |
{status?,description?,name?,skill?};active 不让从 web 切回 |
必填 |
DELETE /v1/tasks/{id} |
软删(204):置 deleted_at,从列表隐藏;messages/usage_events 及工作目录文件全部保留(留作语料 + 可恢复),不动任何磁盘文件;已软删幂等 204 |
必填 |
POST /v1/tasks/{id}/restore |
恢复软删的 task(置 deleted_at=NULL),重新出现在列表;返回 task meta;未软删幂等成功;跨 user / 不存在 → 404 |
必填 |
GET /v1/folders |
列当前 user 工作目录 + n_tasks + last_used | 必填 |
GET /v1/skills |
列当前 user 可用 skill(内置 + 自己的);每项带 source(builtin/user)/overrides_builtin;另返 load_errors(用户 skill 因 frontmatter 坏未加载的) |
必填 |
GET /v1/skills/{name} |
返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
DELETE /v1/skills/{name} |
删当前 user 私有 skill(.skills/<name>/ 整目录);只删 user 源,内置不可删 → 404;.skills 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 |
必填 |
GET /v1/tasks/{id}/messages |
LiteLLM payload 透传 | 必填 |
POST /v1/tasks/{id}/messages |
{content, image_model?=""} 发消息;返 {events_url};run_status 是 running/cancelling → 409(单活 run;error 起新 run 时清);image_model 是 config/media/doubao.yaml image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE done |
必填 |
GET /v1/tasks/{id}/events |
SSE 流(event: <type> + data: <json>);订阅 task 当前活动 |
必填 |
POST /v1/tasks/{id}/cancel |
协作式 cancel;run_status != running → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 |
必填 |
POST /v1/tasks/{id}/clear |
清空当前 task 全部 messages + reset tasks.tokens_prompt/completion/cost_cny 三列累计 + run_status='idle';usage_events(账单记账)不动,只 message_id 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 |
必填 |
POST /v1/tasks/{id}/optimize_prompt |
body {text(req, ≤4000), image_model?=""};同步调当前 task model 润色草稿,返 {optimized, model_profile, tokens_in, tokens_out, cost_cny};不写 messages、不累计 task 三列(顶栏数字不污染),只在 usage_events 写一行 kind="prompt_optimize"(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) |
必填 |
GET /v1/files?path= |
列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
GET /v1/files/download?path= |
下单文件 | 必填 |
POST /v1/files/upload |
multipart 上传到 <user_root>/<path>/;路径不存在自动 mkdir,重名覆盖 |
必填 |
POST /v1/files/delete |
{path, recursive?=false};recursive=false 文件或空目录(非空 → 400);recursive=true shutil.rmtree —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 |
必填 |
POST /v1/files/rename |
{path, new_name};sibling 已存在 → 409;path 顶层目录 → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 |
必填 |
GET /v1/tasks/{id}/export |
对话导出 .docx | 必填 |
GET /v1/models |
列 chat LLM 模型清单(扫 config/models/*.yaml),前端顶栏切换 / 新建对话框下拉用 |
必填 |
GET /v1/image_models |
列图像生成 variant 清单(扫 config/media/doubao.yaml image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 |
必填 |
SSE 事件(每帧 event: <type> + data: <JSON>):run_start{} → llm_start{} → text{delta} / tool_call{name,args,args_preview} / tool_result{name,preview,truncated} → llm_end{prompt_tokens,completion_tokens} → done{};cancel 走 cancelled{} 后随 done{} 收流;异常走 error{msg}。30s 无 event 服务端发 : ping 心跳。nginx 反代记得关 buffering(响应头已带 X-Accel-Buffering: no 默认起效)。
SSE 客户端注意:浏览器原生 EventSource 不支持自定义 header,无法塞 Bearer token。要么 fetch + ReadableStream 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 ?token=... query(目前不支持,避免 token 进 access log)。
部署(Ubuntu / systemd)
单机最小拓扑:
/opt/zcbot/(代码 +.venv+.env)+ systemd unit。按需改路径 / user / port。
一次性
sudo useradd -r -s /sbin/nologin -d /opt/zcbot zcbot # 跑服务的非 root 用户
sudo chown -R zcbot:zcbot /opt/zcbot
# 把 .env 权限收紧(含 JWT_SECRET / PLATFORM_KEY)
sudo chmod 600 /opt/zcbot/.env
sudo chown zcbot:zcbot /opt/zcbot/.env
# PPTX 在线预览(DESIGN §8.3):web 进程(本 host,非 sandbox)调 soffice 把 .pptx 转 PDF。
# 装 LibreOffice Impress + 中文字体(缺则前端 .pptx 自动回退到"下载查看",不报错)。
sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk
unit 文件 /etc/systemd/system/zcbot.service
[Unit]
Description=zcbot web (FastAPI/uvicorn)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=zcbot
WorkingDirectory=/opt/zcbot
# 显式让 systemd 装载 .env(KEY=value 行,不展开 ${...},不留 shell 引号)
EnvironmentFile=/opt/zcbot/.env
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
# ★ 优雅 drain:SIGTERM 后 zcbot 先拒新 run(503)、等在跑的 run 收尾(见
# config/agent.yaml `shutdown` 段:drain_timeout 30s + cancel_grace 15s)。
# TimeoutStopSec 必须 > drain_timeout + cancel_grace + 余量(还要算 sandbox 容器清扫
# + uvicorn graceful 5s),否则 systemd 中途 SIGKILL 把 drain 砍掉、in-flight run 仍
# 被标 error,白做。改 agent.yaml 那两个数值时这里跟着调。
TimeoutStopSec=90
KillMode=mixed
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
启用 + 日常:
sudo systemctl daemon-reload
sudo systemctl enable --now zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -f # 实时日志
sudo journalctl -u zcbot | grep '\[stats\]' # 并发/线程池采样:active_runs 逼近 max_workers 即排队 → 调 ZCBOT_RUN_MAX_WORKERS
sudo systemctl restart zcbot # 重启:先 drain 在跑的 run 再换新版,新发消息期间 503(客户端自动重试)
sudo systemctl stop zcbot
不要再用
kill -HUP:uvicorn 不响应 SIGHUP(没装 handler,落 Python 默认),也不会 reload 代码。Ubuntu 上要么systemctl restart,要么用下面--reload自动模式。
无感更新(对 SSE 也尽量不抖)
底座:systemctl restart 现在优雅 drain 在跑的 run(2026-06-10)。SIGTERM 后 zcbot 先置 draining 拒新 POST /messages(返 503 + Retry-After),等所有在跑的 run 自然收尾再换新版;超 drain_timeout(config/agent.yaml shutdown 段,默 30s)的转协作式 cancel(= 用户按停止,标 idle 不报 error、可重发),再过 cancel_grace(默 15s)仍没退的才留给 SIGKILL。效果:重启不再把正在跑的对话标 error。代价:部署期"新点发送"会吃几十秒 503 窗口 —— dev SPA 已对 503 / 交接拒连退避重试(显"服务更新中"),platform 前端建议加同款。要彻底消灭这个 503 窗口才需要下面 B(蓝绿),A 的 drain 是单实例能做到的上限。前提:unit TimeoutStopSec > drain_timeout + cancel_grace(见上方 unit 注释)。
下面两挡是另一个维度(REST / SSE 抖动平滑),与 drain 正交:
zcbot 现在 5 人级 + SSE 长连接,严格"零中断"(蓝绿 + nginx + SSE 客户端 reconnect 设计)代价高,不值得。有性价比的两挡:
A. 简易档:--reload(推荐当前规模)
ExecStart 加 --reload,git pull 后 uvicorn 监听到文件变动自动重起子进程,REST 抖动 < 1s。代价:SSE 连接被切断,dev.html 自动重连 3 次(1s/2s/4s 退避);若新进程已被启动 reaper 标 run_status=error,重连立即收 done,卡片末尾追加红色"连接已断开,请重发"。期间 LLM 吐的 delta 丢失(broker 不持久化 event,接受)。3 次仍失败 → 同上提示,用户重发即可。
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765 --reload
sudo systemctl restart zcbot 一次生效。之后只 git pull 即可,不用再 restart;改 unit 文件本身才需 daemon-reload + restart。
B. 真无感档:nginx + 蓝绿双实例(将来流量上来再上)
两个 systemd 实例 zcbot@blue / zcbot@green(模板 unit,--port 8765 / --port 8766),nginx upstream 在中间切。流程:
- 部署到空闲实例(假设 green):
sudo systemctl restart zcbot@green curl 127.0.0.1:8766/healthz验新版起来- 改 nginx upstream 指向 green,
nginx -s reload— 新 REST 走 green,旧 SSE 还连在 blue 不断 - 等 blue SSE 自然清空(
ss -tnp | grep :8765为空)再关 blue
zcbot 端额外要做的事:消息 broker 当前在 task 进程内(web/broker.py),蓝绿期间同 task 不同进程会丢事件。nginx 侧用 hash $arg_task_id consistent 保同 task 落同实例可以缓解,但 task 创建分布是另一回事。要做这条得先把 broker 改成 Redis pub/sub。10 人内不推,留到真有需要再上。
部署 SOP
一把梭(推荐):deploy/update.sh
# 在服务器上,root 跑(脚本内部文件操作降到 zcbot 用户)
sudo bash /opt/zcbot/deploy/update.sh
# 或(已带可执行位):sudo /opt/zcbot/deploy/update.sh
脚本顺序写死:git pull --ff-only → pip install -r → db upgrade head → docker build sandbox 镜像 → systemctl restart zcbot → curl /healthz 验活。要点:
- build 必须在 restart 之前:sandbox 容器 per-user 长驻 + 复用,
tools/是 build 进镜像的(非 mount)。restart 时shutdown_all清旧容器,下次ensure()才用新zcbot-sandbox:latest重建 —— 顺序反了新 tools/ 要等下次重启才生效。 - 平台渲染层
rendering/(2026-06-23 起):各 skill 出 docx/pdf 调python /sandbox/rendering/render.py --profile {brief,paper,proposal} --format {docx,pdf}(不再各自带 render_docx.py)。rendering/随pool.pybind-mount 进/sandbox/rendering(restart 重建容器才挂上),pdf 依赖markdown(已进 requirements,镜像重建才内置)+ 镜像自带 chromium。这次升级要整体重建镜像 + restart 一并 deploy——旧 render_docx 路径已删,只推代码不重建会让 brief/paper/proposal/patent/standard 渲染失败。沙盒 chromium 渲 pdf 的冒烟探针:deploy/sandbox/probe_chromium_pdf.sh(服务器上跑,用法见脚本头)。 - sandbox build 每次都跑没关系:layer cache 让重活(pip ~1G / chromium / 字体 / mermaid,都在
COPY tools/之上)在改代码部署时秒过;只有requirements.txt变了才整体重建(~5-10min,正好也是该重建的时候)。host backend 机器 / 临时不想动 docker:sudo bash deploy/update.sh --skip-build。 - 镜像源默认:pip+apt 清华、npm 腾讯(
PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn/simple//APT_MIRROR=mirrors.tuna.tsinghua.edu.cn/NPM_REGISTRY=mirrors.cloud.tencent.com/npm/)。pip 选清华是因为腾讯 PyPI 曾返回损坏的 litellm wheel(index hash 对、文件字节不对 → pipDO NOT MATCH THE HASHES),且阿里 PyPI 又一度滞后(litellm 只到 1.82.6,卡死>=1.83.0);清华境内稳 + 同步及时。npm 用腾讯是因为清华不提供 npm registry、npmmirror 访问不稳,腾讯 npm 历来 OK(坏 wheel 只是腾讯 PyPI 的事,npm 不受影响;备选华为 / USTC npm 源)。要命中 docker cache 就别多组源来回换(换源从 pip 层炸开全重跑)。想用官方源:PIP_INDEX_URL= sudo -E bash deploy/update.sh(置空即回落 Dockerfile 官方默认)。host venv 的 step 2 pip 也吃这个源(脚本显式--index-url,不靠 host pip.conf)。 - 进度可见:step 2 pip 不带
-q,部署时能看到装包进度;step 4 docker build 走默认 TTY 进度 UI(分层折叠刷新,直观)。 - 脚本会自更新重跑:
git pull若动了deploy/update.sh本身,脚本会exec新版本从头重跑(旧脚本的变量默认值在 pull 前就求值了、bash 又按字节偏移边读边跑,不重跑会跑出过期行为 —— 这是首次拉到改 update.sh 的提交时"改了源还报旧错"的根因)。日志出现「update.sh 自身有更新 —— 用新版本重跑」即正常;ZCBOT_UPDATE_REEXEC=1防死循环。 - migration 取 DB URL:
db/migrations/env.py直接读os.environ['ZCBOT_DB_URL'](不读 .env),脚本从.env抠出来显式env ZCBOT_DB_URL=...喂进main.py db upgrade。 - 前置守卫:非 root / 不是 git 仓库 / 工作区脏(已跟踪文件有未提交改动)/ 缺 .env → 直接中止。
/healthz15s 内不返回ok→ dumpjournalctl -n 50并以非零退出。
一次性 bootstrap(useradd / 写 unit /
enable --now)见上方"一次性"段,不在 update.sh 里。改.env/ unit 文件本身后,update.sh 的 restart 一样会让新.env生效;但改 unit 的[Unit]/[Service]字段需先手动sudo systemctl daemon-reload。
手动逐条(脚本跑挂了排查用)
cd /opt/zcbot
sudo -u zcbot git pull --ff-only
sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt
sudo -u zcbot env ZCBOT_DB_URL="$(grep ^ZCBOT_DB_URL= .env | cut -d= -f2-)" \
.venv/bin/python main.py db upgrade head
sudo -u zcbot docker build -f deploy/sandbox/Dockerfile \
--build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) \
-t zcbot-sandbox:latest .
sudo systemctl restart zcbot
curl -fsS http://127.0.0.1:8765/healthz # {"status":"ok"}
sudo journalctl -u zcbot -n 50
Sandbox(Stage C,Ubuntu)
为外部用户开放前必须完成。当前 dogfood + 信任同事白名单阶段可跳过 ── 默 backend = host,
shell/run_python仍走 subprocess(未隔离)。Step 3 已接入 DockerExecutor:ZCBOT_SANDBOX_BACKEND=docker切容器执行;host(默)保留本地 Windows / 同事 dogfood。启用 docker backend 的前置条件:
- 部署机有 docker daemon,zcbot 用户在
dockergroupzcbot-sandbox:latest镜像已 build(HOST_UID/GID对齐).env至少有ZCBOT_PG_IPS=<PG实际IP>(§7.5 #1 PG 单独 block 一遍)- lifespan 启动失败会 fail-fast(
RuntimeError: sandbox init failed),不静默退到 host
镜像构建
容器内 uid/gid 与 host zcbot 用户必须对齐(bind mount 保 host owner;错配导致 EACCES):
# 1) 确保 zcbot 用户存在,uid 拿出来
id -u zcbot # 期望:整数,后面用作 HOST_UID
id -g zcbot # 期望:整数
# 2) 构建镜像(build context = repo 根)
cd /opt/zcbot
sudo -u zcbot docker build \
-f deploy/sandbox/Dockerfile \
--build-arg HOST_UID=$(id -u zcbot) \
--build-arg HOST_GID=$(id -g zcbot) \
-t zcbot-sandbox:latest .
# 境内访问 PyPI 抖动 / ReadTimeout → 加 --build-arg 切换镜像源:
# 腾讯云内网(腾讯云轻量 / CVM 上免外网带宽):
# --build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/
# 阿里云:
# --build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
# 清华:
# --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/
# 镜像源走 https,通常不需 --trusted-host;若用 http 源加
# --build-arg PIP_TRUSTED_HOST=<host_without_scheme>
# apt 源同款(chromium + nodejs + npm 体积大,deb.debian.org 境内慢):
# --build-arg APT_MIRROR=http://mirrors.cloud.tencent.com # 腾讯云内网(推 http,见下)
# --build-arg APT_MIRROR=http://mirrors.aliyun.com # 阿里云
# 推荐 http 而非 https:apt 包用 GPG 签名校验,HTTPS 无额外安全收益,且腾讯云
# 内网 mirror 走 https 偶发触发 OpenSSL 3 严格的 `unexpected eof while reading`
# (旧版 OpenSSL 1.1.1 容忍,新版 fail)。Dockerfile 已加 apt retry=5 + 关
# pipeline + no-cache 兜底偶发抖动。
# npm 源同款(@mermaid-js/mermaid-cli + 依赖,境内访问 registry.npmjs.org 也慢):
# --build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/ # 腾讯云
# --build-arg NPM_REGISTRY=https://registry.npmmirror.com/ # 阿里(npmmirror)
# 镜像内自带 Chromium + mermaid-cli + puppeteer-config.json;mmdc 被 wrapper 包了一层
# (/usr/local/bin/mmdc → 自动注入 -p /sandbox/puppeteer-config.json,除非显式传 -p),
# 所以容器内**裸调 `mmdc -i x.md -o x.png` 就能出图**,无需 --no-sandbox / 自写配置。
# render_diagrams.py 等走 `which mmdc` 的脚本透明受益(原靠 MERMAID_PUPPETEER_CONFIG
# env,已删 ── mmdc 本就不读它,改 wrapper 兜底)。host 上跑无 wrapper,行为不变
# 3) 创建 sandbox 网络(bridge,dogfood 阶段保留 outbound NAT —— 让模型能 pip/curl 公网;
# iptables 仍挡内网/cloud metadata/PG。--internal 完全禁出站是外部用户开放时才改,见 §7.5 #2)
sudo -u zcbot docker network create zcbot-sandbox-net
# 或 SandboxPool.setup_pool() 自动 ensure(ensure_network 即建非 internal bridge)
Sandbox 相关 env(.env 加)
# Backend 选择(默 host):
# host = shell/run_python 走 host subprocess(本地 Windows / dogfood)
# docker = shell/run_python 走 per-user 容器 docker exec(部署机 / 外部用户)
# ZCBOT_SANDBOX_BACKEND=docker
# 容器内 exec 用户(默 zcbot,docker 查容器 /etc/passwd 拿 uid)
# ZCBOT_SANDBOX_EXEC_USER=zcbot
# 容器镜像 tag(默 zcbot-sandbox:latest)
# ZCBOT_SANDBOX_IMAGE=zcbot-sandbox:latest
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
# ZCBOT_SANDBOX_RUNTIME=
# 空闲多少秒回收(默 300)
# ZCBOT_SANDBOX_IDLE_TTL=300
# 资源限制(优先级 env > yaml `sandbox.*` > 默);改后重启 web 新起容器生效
# ZCBOT_SANDBOX_MEMORY=2g
# ZCBOT_SANDBOX_CPUS=1.0
# ZCBOT_SANDBOX_PIDS_LIMIT=256
# ZCBOT_SANDBOX_SHM_SIZE=512m # chromium/mmdc 渲 mermaid 的 /dev/shm(docker 默 64MB 不够会挂超时)
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
workspace 根目录没有 env 覆盖:固定
ROOT/workspace(由 yamlworkspace_dir决定,默workspace)。 要把重写入区落独立数据盘,用 bind mount 把数据盘目录接到ROOT/workspace(逻辑路径不变), 不要指向 ROOT 外的绝对路径 —— DB 的working_dir锚定 ROOT 存相对串,ROOT 外路径会 raise。 详「workspace 落独立数据盘」段。
验证
Step 3 之后,推荐用集成验证(web 起 docker backend + dev SPA 发 shell / run_python 消息):
# 启动 web 时切 docker backend(.env 已设 PG_IPS / SANDBOX_BACKEND=docker)
ZCBOT_SANDBOX_BACKEND=docker .venv/bin/python main.py web
# 触发任一 shell / run_python 消息后,容器应已起
sudo -u zcbot docker ps --filter label=zcbot.product=sandbox
# 应看到 zcbot-sandbox-<your-uid>,STATUS = Up ...
# 5 分钟无新消息后 reaper 自动 rm
也可直接起一个测试容器单验 hardening(不依赖 web 进程):
USER_ID=00000000-0000-0000-0000-000000000001
sudo -u zcbot docker run -d \
--name zcbot-sandbox-$USER_ID \
--label zcbot.product=sandbox \
--label zcbot.user_id=$USER_ID \
--network zcbot-sandbox-net \
--read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
--cap-drop=ALL --cap-add=NET_ADMIN \
--security-opt=no-new-privileges \
--pids-limit=256 --memory=2g --cpus=1.0 \
-v /opt/zcbot/workspace/users/$USER_ID:/workspace \
-e ZCBOT_PG_IPS=10.1.2.3 \
zcbot-sandbox:latest
# 看 iptables 规则确实 apply 了(应 6+1 条 DROP)
sudo -u zcbot docker exec zcbot-sandbox-$USER_ID iptables -L OUTPUT -n --line-numbers
# 看 non-root 用户(uid 应 = host zcbot uid)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
zcbot-sandbox-$USER_ID id
# 看 rootfs read-only(应报 Read-only file system)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
zcbot-sandbox-$USER_ID touch /badtest
# 销毁
sudo -u zcbot docker rm -f zcbot-sandbox-$USER_ID
Step 4 引入 egress proxy 后,完整 5 条红队用例(metadata / loopback / 跨 user / nohup
残留 / allowlist 外 403)进 tests/test_sandbox_redteam.py 自动化跑。
Stage C 完整部署速查
首次部署 / 改了 init.sh / Dockerfile / requirements.txt → 重 build:
cd /home/lighthouse/zcbot
docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile \
--build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) \
--build-arg APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn \
--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ \
--build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/ \
.
只改 host 侧 docker run flag / pool.py(没动 Dockerfile / init.sh)→ 不用重 build, 清旧容器 + 重启 web 即可:
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
systemctl restart zcbot
跑 migration(新增 / 修改了 db/migrations/versions/* / models.py):
.venv/bin/python main.py db upgrade head
启用 docker backend 重启 web(确保 .env 有 ZCBOT_SANDBOX_BACKEND=docker 或 systemd
unit 已设):
systemctl restart zcbot
journalctl -u zcbot -e --no-pager | tail -30 # 看 startup log
# 应见:
# [startup] [warn] fs quota: ext4 on ... (dogfood 阶段 warn 正常)
# [startup] swept N stale sandbox container(s)
# [disk_scanner] initial scan: N user(s)
# INFO: Uvicorn running on 0.0.0.0:8765
日常运维 / 验证
# 当前活跃 sandbox 容器
docker ps --filter label=zcbot.product=sandbox \
--format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
# 某容器跑了什么(看 docker run 完整参数 + env + mount)
SBC=$(docker ps -q -f label=zcbot.product=sandbox | head -1)
docker inspect $SBC --format '{{json .Config.Env}}' | python3 -m json.tool
docker inspect $SBC --format '{{json .HostConfig.Binds}}'
# 容器内 DNS + 网络验证(应见 nameserver 8.8.8.8 / 114.114.114.114)
docker exec $SBC cat /etc/resolv.conf
docker exec $SBC getent hosts www.baidu.com
docker exec $SBC curl -sI -m 5 https://www.baidu.com | head -1
# iptables 规则(应见红线段 DROP + 127.0.0.11:53 ACCEPT 例外)
docker exec $SBC iptables -L OUTPUT -n --line-numbers
# 容器内 zcbot user 与 host 对齐(uid 应一致)
docker exec $SBC id
id # host 上跑
# 容器资源占用
docker stats --no-stream $SBC
# 看本会话 sandbox 启动 stderr(init.sh 的 [init] ... 行)
docker logs $SBC
# 杀某 user 容器(强制下次 ensure 重建,可用于 hotfix 后切镜像)
docker rm -f zcbot-sandbox-<user-uuid>
# 清所有 sandbox 容器(reaper 兜底,不影响 web 进程)
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
磁盘配额查看(0008 表):
# 当前所有 user 用量
psql "$ZCBOT_DB_URL" -c "
SELECT user_id, bytes_used/1024/1024 AS mb, file_count, scanned_at
FROM user_disk_usage ORDER BY bytes_used DESC;"
# 强制扫描某 user(改 yaml interval 太久不耐烦时)
.venv/bin/python -c "
from pathlib import Path
from uuid import UUID
from core.storage.disk_quota import scan_user_dir, upsert_user_usage
uid = UUID('<your-user-uuid>')
b, c = scan_user_dir(Path('/home/lighthouse/zcbot/workspace/users') / str(uid))
upsert_user_usage(uid, b, c)
print(f'{b/1024/1024:.1f} MB / {c} files')"
DNS / resolv.conf 文件位置(host 侧):
# 由 SandboxPool 在首次 _docker_run 时生成,每次重建容器会覆盖刷新
cat /home/lighthouse/zcbot/workspace/.sandbox/resolv.conf
部署前置对账
切 ZCBOT_SANDBOX_BACKEND=docker 之前跑一次:
sudo -u zcbot .venv/bin/python main.py sandbox check
输出形如 [ok] / [warn] / [err] × 5 项 + 汇总 N/5 passed,exit code 0=可启动 / 1=有 err
要修。5 项对应:① docker daemon 可达 ② zcbot-sandbox:latest 镜像存在 ③
zcbot-sandbox-net network 存在(缺也能跑,lifespan 自动 ensure)④ 镜像内 zcbot
uid 与 host uid 对齐(错配 → exec 写 /workspace 全 EACCES)⑤ workspace/users/
所在 fs 类型可 quota。
lifespan 启动时同样会打第 ⑤ 项的 WARN 到 stdout([startup] [warn] fs quota ...),
应用层周期扫描仍生效;仅外部用户开放前必须把 ⑤ 升级到 OS 层 quota。
workspace 落独立数据盘(prod,大空间 + quota fs)
prod 的 workspace/users/<uuid>/ 是重写入区(报告 / 图 / pptx 等大件落这,DB 只存元数据)。
推荐挂一块独立数据盘(xfs prjquota),空间和 OS 层配额一步到位。
手法是 bind mount,不是 env 覆盖。 代码把 workspace 固定解析成 ROOT/workspace,且 DB 的
working_dir 经 core/paths.py 锚定 ROOT 存相对串(to_db_path 对 ROOT 外的绝对路径直接
raise)。所以不能让 workspace 指向 ROOT 外的 /data/...(三处会各算各的:文件面板走
resolve_workspace 看数据盘、agent 走 from_db_path 看 ROOT/workspace、新建 task 直接 500)。
正解:让物理数据落 /data,但逻辑路径仍是 ROOT/workspace —— 用 bind mount 把数据盘目录接上去。
bind 不像 symlink 会被 .resolve() 展开,内核路径保持 ROOT/workspace,relative_to(ROOT) 照常过,
DB 一个字不用改,dev 不受影响(dev 不 bind,直接用本地 ROOT/workspace)。
PG 不必跟着搬:它是元数据库,长期个位数~几十 G,根盘够用;留默认 /var/lib/postgresql/<ver>/main
更省坑(pg_ctlcluster / AppArmor 按标准路径来)。等 pg_database_size 真奔 30–40G、根盘紧了
再迁,那时 /data 下加个 postgresql/ 子目录布局照样兼容。
# 假设新盘是整块裸盘 /dev/vdb(lsblk 看,无分区表直接整盘格,数据盘惯例)
# 0) 停服务(迁移时别再写 workspace)
sudo systemctl stop zcbot
# 1) 整盘格 xfs
sudo mkfs.xfs /dev/vdb
# 2) 写 fstab(UUID + prjquota),挂 /data
sudo mkdir -p /data
UUID=$(sudo blkid -s UUID -o value /dev/vdb)
echo "UUID=$UUID /data xfs defaults,prjquota 0 0" | sudo tee -a /etc/fstab
sudo mount -a
findmnt -no FSTYPE,OPTIONS /data # 期望:xfs ... prjquota
# 3) 准备数据盘上的物理目录 + 迁现有 workspace 数据(prod 若还空则跳过 rsync)
sudo mkdir -p /data/zcbot/workspace
sudo rsync -aXS /home/ubuntu/zcbot/workspace/ /data/zcbot/workspace/
sudo chown -R ubuntu:ubuntu /data/zcbot/workspace # uid 必须 == 容器内 zcbot(HOST_UID)
# 4) bind mount:物理 /data/zcbot/workspace,逻辑仍是 ROOT/workspace(写 fstab 持久化)
sudo mkdir -p /home/ubuntu/zcbot/workspace
echo "/data/zcbot/workspace /home/ubuntu/zcbot/workspace none bind 0 0" | sudo tee -a /etc/fstab
sudo mount -a
findmnt /home/ubuntu/zcbot/workspace # 确认 bind 上了,FS 显示落 /data 那块盘
sudo systemctl start zcbot
python3 main.py sandbox check # fs quota 那项应变 [ok](探测的是 bind 后的真实 fs)
确认 sandbox check 通过、新 task 文件确实落到 /data 那块盘后即可。/data/zcbot/workspace 下叫什么、
嵌几层随意,关键是 bind 到 ROOT/workspace。
开机顺序硬化(强烈建议):给 systemd unit
[Service]加RequiresMountsFor=/home/ubuntu/zcbot/workspace, 否则若开机时 bind mount 还没就绪,service 抢先启动会把文件写进根盘那个空壳ROOT/workspace(数据分叉)。 加完sudo systemctl daemon-reload。
配额硬化(§7.5 #4,外部开放前必做)
应用层磁盘配额能挡常规超额,但扫描间隙打满共享 fs 拖死同节点这条硬要 OS 层
quota。sandbox check 第 ⑤ 项会探测当前 fs 状态:
| 探测结果 | 含义 | 处理 |
|---|---|---|
fs quota: xfs with prjquota on ... |
ok,可直接 xfs_quota -x 给 user 加配额 |
(无需处理) |
fs quota: ext4 with project quota on ... |
ok,可 quota -P |
(无需处理) |
fs quota: zfs on ... |
ok,在 dataset 层 zfs set quota= |
(无需处理) |
fs quota: xfs ... NO prjquota mount option |
fs 支持但 mount 时没启 | 见下方 xfs 步骤 |
fs quota: ext4 ... NO project quota option |
同上 | sudo tune2fs -O project,quota <dev> + remount |
fs quota: btrfs ... |
qgroup 配置复杂 | 生产推荐换 xfs 单独分区,或自行验 btrfs qgroup |
fs quota: tmpfs/overlay/... |
通常 Docker-in-Docker 或本地 dev | 生产必须挂独立分区 |
xfs 升级步骤(推荐方案):
# 1) 确认 workspace 在哪个 mount(假设 /opt 是独立 xfs 分区)
findmnt --target /opt/zcbot/workspace
# 2) 启用 prjquota(写入 /etc/fstab 让 reboot 后保留)
sudo mount -o remount,prjquota /opt
# 3) 给某 user 加 project quota(<pid> 自定义整数 id,与 user_id 映射建表跟踪)
echo "1001 /opt/zcbot/workspace/users/<user_uuid>" | sudo tee -a /etc/projects
echo "zcbot_<user_uuid>:1001" | sudo tee -a /etc/projid
sudo xfs_quota -x -c "project -s zcbot_<user_uuid>" /opt
sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
<pid> ↔ user_uuid 映射手工维护(/etc/projects 是数字 id,zcbot 侧需建表追踪;
首期外部开放前补一个 main.py sandbox quota-set --user-id <uuid> --gb 10 子命令
读写 /etc/projects + 调 xfs_quota,这是 Step 4 / 5 之后真上线前一步,当前不做)。
不做这步等于"软配额 + 信任用户不写满" -- dogfood + 信任同事白名单阶段够用, 外部用户开放是 hard prereq。
故障兜底
| 现象 | 原因 / 处理 |
|---|---|
ZCBOT_DB_URL is not set |
.env 没写 / litellm 链路没触发。直跑脚本时 import litellm 或 export ZCBOT_DB_URL=... |
ModuleNotFoundError: litellm |
用了全局 python,改 .venv/Scripts/python.exe ... |
| Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 [OK] / [ng] 等 ASCII 标签(见 memory) |
db upgrade 报 column already exists |
DB 已被改过,db current 确认 revision,必要时手 ALTER 或 db downgrade base 重来 |
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 curl /v1/tasks 拉 |
| task 删了文件还在 | 现在 DELETE /v1/tasks/{id} 是软删,本就不动任何磁盘文件(留作语料 + 可恢复);要清磁盘走 POST /v1/files/delete。彻底物理删 task(及 messages)留给将来的管理员清理工具;当前如需手动:psql> DELETE FROM tasks WHERE task_id=...(messages/usage_events CASCADE) |
Sandbox 容器内 touch /workspace/x 报 Permission denied |
容器 uid 1000 与 host zcbot 用户 uid 不一致(bind mount 保 host owner)。docker build --build-arg HOST_UID=$(id -u zcbot) 重建镜像 |
Sandbox 容器 build 完起不来,docker logs 显示 iptables 报错 |
缺 NET_ADMIN cap(--cap-add=NET_ADMIN 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:docker exec ... iptables -V |
启动报 ZCBOT_SANDBOX_BACKEND=docker but sandbox init failed: ... |
docker daemon 没起 / 用户不在 docker group / network create 失败。先跑 main.py sandbox check 看哪一项 err |
[startup] [warn] fs quota: <fstype> on ... |
workspace 所在 fs 没启 OS 层 quota。dogfood 阶段忽略;外部用户开放前必须升级 xfs prjquota / ext4 project / zfs(详 RUN.md「配额硬化」段) |
| prod 想把 workspace 落独立数据盘 | 别用 env / 别指 ROOT 外绝对路径(workspace 锚定 ROOT,ROOT 外会让文件面板 / agent / 新建 task 三家分叉)。用 bind mount 把 /data/... 接到 ROOT/workspace,逻辑路径不变,DB 不用改。详「workspace 落独立数据盘」段 |
| 文件面板"目录尚未创建"但文件确实在 / agent 写的文件面板看不到 | workspace 被指到了 ROOT 外(旧 ZCBOT_WORKSPACE_DIR 绝对路径残留)→ 文件面板走 resolve_workspace 看一处、agent 走 DB from_db_path(锚 ROOT)看另一处。删掉 env、改用 bind mount(见上段),三家归一 |
docker run zcbot-sandbox:latest 报 Unable to find image |
镜像没 build。sudo -u zcbot docker build -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) -t zcbot-sandbox:latest . |
镜像 build pip 报 THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE(本仓 requirements 未钉 hash) |
不是被篡改、也不是 require-hashes:镜像 index 声明的 wheel hash 与它实际吐出的文件字节不符 = 该镜像存的文件损坏 / 截断(2026-06-03 腾讯源就这么坏过 litellm-1.87.0)。换源重 build:PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ sudo -E bash deploy/update.sh。验真伪:https://pypi.org/pypi/<pkg>/<ver>/json 看官方 sha256 是哪边对。与下面"版本滞后(Could not find)"是两回事 |
镜像 build pip 报 ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...) |
境内访问 PyPI 抖动。加 --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/(清华,现默认)或腾讯 / 阿里源,详 RUN.md「镜像构建」段。Dockerfile 已把 pip timeout 拉到 60s,主因仍是源不通而非超时 |
pip 报 Could not find a version that satisfies the requirement litellm>=1.83.0(伴随一串 Ignored ... yanked versions: 0.1.xxxx) |
用的镜像源同步滞后,没有该新版本。阿里 PyPI 一度只到 litellm 1.82.6 —— update.sh 默认已是清华源(同步及时)。若手动 build 撞到:换 PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ 或腾讯源。那串 0.1.xxxx 是 litellm 远古版本,纯干扰信息 |
| 镜像 build npm 装 mermaid-cli 慢 / fail | npm 源境内慢。默认已用腾讯 https://mirrors.cloud.tencent.com/npm/(清华无 npm 源;npmmirror 访问不稳被弃)。备选:华为 https://repo.huaweicloud.com/repository/npm/ / USTC https://npmreg.mirrors.ustc.edu.cn/,手动 build 加 --build-arg NPM_REGISTRY=... |
镜像 build apt 报 OpenSSL error: ... unexpected eof while reading |
某些 mirror HTTPS 端偶发 close_notify 缺失,OpenSSL 3 严格 fail(腾讯 / 阿里见过;清华一般不犯)。改用 http 形式:--build-arg APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn(apt 包 GPG 签名校验,无 HTTPS 安全收益)。Dockerfile 已配 apt retry=5 + 关 pipeline,重 build 一般直接过 |
容器内 shell 写工作目录报 Permission denied(but sandbox check ⑤ HOST_UID aligned ok) |
DockerExecutor 写死了 --user 1000:1000 不会自动跟 build 的 HOST_UID 同步(改 --user zcbot 后已修)。仍报错检查镜像内 docker run --rm --entrypoint id zcbot-sandbox:latest zcbot 输出 uid 是否 = id -u $(whoami) |
容器内 mmdc 渲 mermaid 报 Failed to launch chromium / No usable sandbox |
chromium 在 --cap-drop=ALL 下自家 setuid sandbox 起不来,要 --no-sandbox。镜像已落 /sandbox/puppeteer-config.json + 给 mmdc 套了 wrapper 自动 -p 注入 ── 裸调 mmdc -i x -o y 就该成。仍跪:docker exec ... cat /usr/local/bin/mmdc 看 wrapper 在不在(老镜像没 rebuild 则没有);或显式 mmdc -p /sandbox/puppeteer-config.json -i x -o y |
| 容器内 mmdc 渲图卡到 timeout 而非报错 | chromium 默认用 /dev/shm,docker 不传 --shm-size 时只 64MB → 起不来一直挂。已在 pool.py 给 docker run 加 --shm-size(默 512m,env ZCBOT_SANDBOX_SHM_SIZE / yaml sandbox.shm_size)。已 running 的旧容器不会自动生效,重启 web + 等 idle 回收(或 docker rm -f zcbot-sandbox-<uid>)后新起的才带。实测脚本 deploy/sandbox/probe_mermaid.sh |
模型不渲本地 mmdc、反复试 mermaid.ink 等在线渲图服务还失败 |
容器有外网(bridge+NAT),但 mermaid.ink 等境外服务易被墙/不稳,渲图不该依赖出站。docker backend 的 system prompt「运行环境」段(agent_builder.py 注入)已写明"渲图走本地 mmdc、别调在线服务";撞到多半是 prompt 没更新 / 跑在 host backend。渲 mermaid 一律 mmdc -i x.md -o x.png |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
NoSubtaskError: working_dir ... 前缀嵌套 |
§7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。同项目多对话用完全相同的 working_dir;否则改成 sibling(平级) |
main.py web 启动后 curl 连不上 |
检查 proxy(HTTP_PROXY / HTTPS_PROXY):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 unset HTTP_PROXY HTTPS_PROXY 或 curl --noproxy '*'。验通:curl --noproxy '*' http://127.0.0.1:8765/healthz |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 X-Accel-Buffering: no(nginx ≥1.5.6 默认认)。仍卡看 nginx 配 proxy_buffering off; proxy_read_timeout 3600s; |
| platform CORS preflight 失败 | 本地 dev allow_origins=["*"] 应该没事;部署后看是否按 platform 域名收紧过头 |
POST /v1/tasks/{id}/messages 返 409 task already has an active run |
上一条消息的 BG run 还没跑完;等流式 done 或点 stop / POST .../cancel;服务异常下 run_status 卡 running/cancelling,启动 reaper 会清 |
POST /v1/tasks/{id}/cancel 返 409 task not running |
run_status 不是 running(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 |
POST /v1/tasks/{id}/clear 返 409 task has an active run |
当前 run 还没跑完;先点停止 / POST .../cancel 等流式 done 再清空 |
| 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) |
[startup] reaped N stale active run(s) |
上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
seedream tool 没出现在对话里 |
.env 没设 ARK_API_KEY,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 |
document_* tool 没出现在对话里 |
.env 没设 DOCUMENT_SEARCH_API_KEY,build_agent 跳过注册。设了重启 web 即可;key 不进入 sandbox。 |
文件区点 .pptx 弹"服务器未装 LibreOffice"/ 直接回退下载 |
web host(非 sandbox)没装 soffice。sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk 后重启 web。dev(Windows)winget install TheDocumentFoundation.LibreOffice。验:soffice --version 或 python -c "from web.pptx_render import find_soffice; print(find_soffice())" |
.pptx 预览首次慢几秒 |
正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 .preview/<stem>.<hash>.pdf,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |
mp_* tool 没出现在对话里 |
.env 没设 MP_API_KEY,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 |
| 豆包调价了 | 改 config/media/doubao.yaml 的 price_cny_per_image 一行 → 重启 web。历史 usage_events 不受影响(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
kill -HUP <pid> 后 /openapi.json 没新接口 |
uvicorn 不响应 SIGHUP(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 systemctl restart zcbot,或 unit 加 --reload 让 uvicorn 监听文件自动重起(见"部署"段)。验证:curl -s http://127.0.0.1:8765/openapi.json | python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])' |
systemctl restart zcbot 要等几十秒才退 |
正常 —— 优雅 drain 在等在跑的 run 收尾(shutdown.drain_timeout 默 30s),没在跑 run 时秒退。journal 出现 [shutdown] draining N in-flight run(s) 即正常。真急(不在乎杀掉在跑 run):systemctl kill -s KILL zcbot |
部署后在跑的对话被标 error: server restarted before run finished |
该 run 在 drain 期内没收尾、cancel 也没在 cancel_grace 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 TimeoutStopSec 配得比 drain 预算还小被提前 SIGKILL。先核对 unit TimeoutStopSec > drain_timeout + cancel_grace;真有超长 run 把 drain_timeout 调大 |
| 定时任务「跑到一半没推送」/ crons 页显示「上次失败: 运行超过超时上限 Ns 未完成」 | job 跑满 timeout_seconds 被协作式中断(还没写完 / 没推送)。0.32.2 起超时记 error(此前误记 ok 看不出来),计入连续失败、到阈值自动停用。处置:该 job 调大 timeout_seconds(报告类重活如多刊检索+渲 docx 建议 ≥1800,或 0=不限),被自动停用的重新 enable。诊断单个 job 用 scripts/diag_sched_e621.py <job_id 前缀> |
POST /v1/files/rename 返 409 folder has active run(s) |
顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
POST /v1/files/rename 返 409 ... 前缀嵌套 |
改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
POST /v1/files/upload 返 413 已达磁盘配额上限 |
per-user 5GB(yaml quotas.disk_bytes_per_user)。让用户在 dev SPA 右侧文件栏删旧产物 / 大文件,或改 yaml 升配重启 web |
[warn] network zcbot-sandbox-net is --internal (legacy) |
上一版 sandbox network 创建时带了 --internal(完全禁 outbound),当前 dogfood 阶段放开。docker stop $(docker ps -aq -f label=zcbot.product=sandbox) ; docker network rm zcbot-sandbox-net,重启 web 自动 recreate 为非 internal |
tool write/edit 返 [Error] 已达磁盘配额上限 |
同 upload 413,见上 |
容器内 curl https://www.baidu.com 报 Temporary failure in name resolution |
docker user-defined bridge network 上 /etc/resolv.conf 默 nameserver 127.0.0.11(embedded DNS),腾讯云轻量等场景 daemon 探测上游失败 → embedded DNS forward 跪。修法:yaml sandbox.dns 指定 [8.8.8.8, 114.114.114.114],SandboxPool 把 host 侧 <workspace>/.sandbox/resolv.conf 文件 bind mount -v ...:/etc/resolv.conf:ro 覆盖容器默 ro mount,绕开 embedded DNS。docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) + systemctl restart zcbot 让新容器按 mount 配置生效 |
init.sh 报 /etc/resolv.conf: Read-only file system |
docker 默把 /etc/resolv.conf 当 ro mount,init.sh 内 cat > 写不进。host 侧 bind mount 已是主路径(见上),init.sh 写仅作 fallback 且失败 robust 不退出容器 |
启动报 PLATFORM_KEY env not set / JWT_SECRET env not set |
D' 过渡 auth 强制双 env 必填。生成 python -c "import secrets;print(secrets.token_urlsafe(48))" 各填一,写 .env 重起 |
/v1/auth/login_password 返 403 invalid email or password |
邮箱不存在 / password_hash 列为空(platform_key 入口建的 user) / 密码错。SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=... 核对;无行 → main.py user add;有行无密码 → UPDATE users SET password_hash=...(用 .venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))" 算)或 user add --user-id 接到现有 user_id |
main.py user add 报 IntegrityError ... uq_users_email |
邮箱已存在,改 email 或先 DELETE FROM users WHERE email=...(先清该 user 的 tasks) |
main.py user add 报 IntegrityError ... users_pkey |
--user-id 撞已有 UUID,换一个或不传让随机生成 |
登录页"+ 管理员添加用户"提交后 503 admin create_user disabled |
ZCBOT_ADMIN_TOKEN env 未设,功能默关。设了 env 重启 web 即可;或临时回退 main.py user add |
登录页"+ 管理员添加用户"返 403 invalid admin_token |
弹窗里管理员口令栏填错或没复制完整。跟 .env 里 ZCBOT_ADMIN_TOKEN 比对(注意末尾空格 / 引号) |
| 改了用户邮箱 / 密码后他登不上 | UPDATE users SET email=... 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 UPDATE users SET password_hash=<bcrypt> 同理。用户知道旧密码时优先让他用顶栏「改密码」自助,只有忘了旧密码 / 改邮箱才手动 SQL |
顶栏「改密码」返 403 该账号未设置密码 |
该 user 是 platform_key(UUID+PLATFORM_KEY)入口建的占位行,password_hash 为空,无旧密码可验。先手动 UPDATE users SET password_hash=<bcrypt> 设一个,再让他用密码登 + 自助改 |
/v1/* 全返 401 missing Authorization: Bearer |
没拿 token 或没带 header。先 login 拿 token,curl 加 -H "Authorization: Bearer $TOKEN" |
/v1/* 返 401 token expired |
JWT 默 7d TTL 到期,重 login。要更长改 ZCBOT_JWT_TTL_SECONDS env |
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 fetch + ReadableStream。devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login |
| matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 fonts-noto-cjk fonts-wqy-microhei + fc-cache,但改了 Dockerfile 必须重 build 镜像 + 清旧容器才生效(旧容器仍跑老镜像):docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) . → docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) → systemctl restart zcbot。验证:docker run --rm zcbot-sandbox:latest fc-list :lang=zh 应列出 Noto/WenQuanYi |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(--reload 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
对话里偶发 [Error] invalid JSON arguments / [Error] bad arguments to write: ... missing required |
deepseek-v4-flash 大参数工具调用(大 write/run_python,≈7K+ 字符)偶发把内容碎片错位粘进 arguments 或退化成空 {}(上游流式抖动)。core/loop.py 已自动兜底:畸形参数丢弃整轮重 roll(≤3 次)+ 最后一次降级非流式。仍频繁撞 → 引导模型把大文件拆小 / 用 run_python 分块写,或换 deepseek_v4.pro。前端看到 warn「工具调用参数损坏…重试」即此机制在生效 |
| 长任务跑到一半停下、提示「已达单轮步数上限…回复『继续』可接着跑」 | 预期行为非崩溃:单个 run 自主步数到 backstop(config/models/*.yaml 的 max_iterations,flash 120 / pro 150)就主动停,回 [reached max iterations]。直接回复「继续」即续跑。经常撞顶=任务确实大,可调高对应 variant 的 max_iterations 或换 pro |
任务停下提示「连续 N 步无净产出…已自动停止」(回 [stopped: no progress]) |
空转熔断:连续 _STALL_LIMIT(loop.py,默认 8)步所有 tool 都只返 [Error]/重复结果/被拦 = 没在推进,主动停以免空烧 token。说明模型卡死在某个错上——换思路 / 补充信息再回复「继续」,别原样重发(会再次撞同一墙) |
关键路径与文件
- 入口:
main.py(web / db / probe / user)→core/agent_builder.py::build_agent - 核心:
core/{agent_builder, loop, session, task, llm, memory, paths}.py+core/storage/{engine,models,utils}.py+db/migrations/ - 工具:
tools/{fs, shell, run_python, skill_tool}.py - Web:
web/{app.py, auth.py, broker.py, sinks.py}+web/static/dev.html(dev SPA)+web/static/vendor/(office 预览 jszip/docx-preview/xlsx) - 配置:
config/agent.yaml+config/models/*.yaml(§3.2 Model Profile)- 模型档位(per-account 模型访问):
config/agent.yamlmodel_tiers段定义「档位→可用模型 id 集合」;users.plan存档位名,空/未知 →default档,role=admin全开。管理后台「各用户用量」表的「档位」下拉改 plan(PATCH /v1/admin/users/{uid}/plan);档位定义见GET /v1/admin/tiers。改model_tiers后重启 web 生效;无需 migration(plan列 0001 起就有)。模型 id:文本=family.variant,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回deepseek_v4.flash。
- 模型档位(per-account 模型访问):
- Skill:
skills/{coding,ppt,proposal}/SKILL.md(渐进披露,§3.5) - Workspace(per-user 子树,user_id 来自 JWT
sub):workspace/users/<user_id>/.memory/{core.md, extended/}— 跨 task 记忆,FS 永久,dotfile 隔离workspace/users/<user_id>/.skills/<name>/SKILL.md— 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具save_skill/fork_skill写(host-side,不走沙箱 fs);docker 下随 user_root bind 到/workspace/.skillsworkspace/users/<user_id>/<working_dir>/— 工作目录,用户起名,同 working_dir 多 task 共享
维护约定
- 改对外行为(CLI 选项 / env / 文件布局)→ 同步本文档。bug 修不动这个,只动 PROGRESS。
- 故障兜底新增:用过一次的真实坑,写一行,不预测。
- 跟 DESIGN/PROGRESS 边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。