zcbot/DESIGN.md

405 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 设计文档
> 一个本地运行的个人任务 agent。覆盖三类工作:写汇报 PPT、写科研申报书、写代码。
> 模型自由(LiteLLM 接 OpenAI-compatible),代码可控(目标 1500-2000 行 Python,自己读得懂)。
---
## 1. 边界
### 做什么
- **PPT**:文本 / 会议纪要 → `.pptx`(用 `python-pptx`)
- **科研申报**:课题信息 → 分章节 `.docx`(用 `python-docx`)
- **编码**:文件编辑、shell 执行、迭代验证
### 不做什么
- 子 agent / IM 渠道 / 多用户 / Web UI(初期 CLI 即可)/ 自定义 RAG / 锁定 Anthropic
- **Eval Suite**:个人工具用 dogfooding 判断模型升级,造作 case 没区分度
### 关键约束
- 模型自由:LiteLLM 接 OpenAI-compatible 任意 provider(默认 DeepSeek V4)
- 任务持久化:任意时刻关机,下次能恢复
- 演化性:模型升级时 agent 跟着升级,不需要大改架构
---
## 2. 架构
### 目录树(实际)
```
zcbot/
├── core/
│ ├── capabilities.py # ModelCapabilities,从 yaml 加载
│ ├── llm.py # LiteLLM 封装,按 capabilities 自动启用 features
│ ├── loop.py # ReAct 主循环
│ ├── probe.py # 真实探测对账 yaml 声称的能力
│ ├── session.py # 消息列表 + meta + 落盘 messages.json
│ ├── skills.py # SkillRegistry (Anthropic 渐进披露格式)
│ └── task.py # TaskState (mode/desc/status/tokens/timestamps)
├── 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
├── skills/
│ ├── coding/ # SKILL.md
│ ├── ppt/ # SKILL.md + references/ + scripts/ + assets/
│ └── proposal/ # SKILL.md
├── prompts/system/
│ └── general_v1.md
├── config/
│ ├── agent.yaml
│ └── models/
│ └── deepseek_v4.yaml # flash + pro 两档
├── workspace/
│ └── tasks/<task_id>/
│ ├── state.json # TaskState
│ ├── messages.json # Session
│ ├── spec_lock.md # skill 阶段一产物 (proposal/ppt)
│ ├── source/ # proposal 用户素材 (PDF / 团队介绍)
│ ├── source.md # ppt 转过的素材
│ ├── sections/ # proposal 逐章 md (01_summary.md ... 12_appendix.md)
│ ├── slides/ # ppt 中间素材 (chart_p?.png)
│ └── <topic>.docx / .pptx # 最终产物
├── main.py # 装配 (build_agent)
└── cli.py # CLI: chat / tasks / probe
```
**task_dir = `workspace/tasks/<task_id>/`,所有 skill 产物都写到这里**。task_dir 绝对路径在 system prompt 里显式给 agent,SKILL.md 的 `<task_dir>` 占位符指向它。如果 agent 写错位置(写到 cwd / `skills/` / repo 根),git status 会立刻报红 —— `.gitignore` 不再用无锚通配规则盖住污染。
### 启动时拼装顺序
1.`config/agent.yaml` 拿 default_model
2. `ModelCapabilities.load("deepseek_v4.flash", config/models/)` 拿能力档案
3. `LLM(caps)` 构造,从 env 读 API key
4. 解析 task_dir(新建 or resume)
5. 拼 system prompt:`prompts/system/general_v1.md` + `SkillRegistry.discovery_block()`(skill 列表)+ cwd + **task_dir 绝对路径**(产物根)
6. 装配工具集(fs / shell / load_skill / run_python)
7. 启动 REPL —— **新建路径不预占文件**(懒创建,见 §3.6)
---
## 3. 核心组件
### 3.1 主循环(`core/loop.py`)
ReAct 风格:LLM → 若有 tool_calls 就执行 → 把结果塞回消息列表 → 再调 LLM。无 tool_call 即返回。
- 工具结果对模型截断到 16K 字符,对用户预览 400 字符
- thinking spinner 由后台 daemon 线程每 100ms 刷新文本:`thinking... 1.3s ctx 12,345 tok`(累计 token 反映上下文大小)
- 每轮 LLM 返回追加 dim 一行 `[in N out N t Xs]` —— 留痕本轮成本
- assistant 文字走 `rich.markdown.Markdown` 渲染,粗体/列表/表格/代码块正常展示(非流式,整段渲染)
- `max_iterations` 从 capabilities 读,不同模型不同
### 3.2 Model Profile(`core/capabilities.py` + `config/models/*.yaml`)
**核心思想**:每个模型一份 yaml 档案,agent 行为按档案动态调整。新模型 5 分钟接入,不改代码。
`ModelCapabilities` 字段: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` + `cli.py probe`)
yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账:
- `basic_chat`:连通性
- `parallel_tools`:给两个独立工具,看 single response 是否 ≥2 个 tool_calls
- `thinking_mode`:对 declared=True 的模型试 reasoning_effort,看 API 是否接受 + 是否产 reasoning_content
- `long_context`:needle-in-haystack 简化版(opt-in,默认关)
不修改 yaml,只输出 rich Table 报告。退出码 0/2/3 区分 ok / mismatch / error。**显式触发,不进启动路径**(每次启动跑会烧 API)。
### 3.4 工具系统(Hybrid 范式)
**两类工具并存**:
- **JSON tool call**(`tools/`):read / write / edit / glob / grep / shell / run_python / load_skill —— 处理离散操作
- **Code execution**(`run_python`):tmp `.py` + subprocess + 工作目录限制 + 敏感 env 过滤(`*API_KEY *TOKEN *SECRET *PASSWORD *PRIVATE_KEY`)—— 处理批处理 / 算数据 / 生成文档
**关键设计**:
- `edit` 用 **唯一匹配**约束(CoreCoder 风格):old_str 必须只出现一次,否则报错。防 LLM 改错地方。
- 工具按**原子操作**切分,不做高级封装。`make_pptx()` ❌,`run_python(code)` 调 `python-pptx` ✅。粒度太粗会接收不到模型升级红利。
### 3.5 Skill 系统(Anthropic 渐进披露标准)
对齐 Anthropic 2025-12 开放标准,跨平台兼容(Claude Code / Codex CLI / Gemini CLI 都用)。
**三层加载**:
| 层 | 时机 | 内容 | Token |
|---|------|------|------|
| Discovery | agent 启动 | 仅 `name + description`,所有 skill 都读 | 几百 |
| Activation | `load_skill(name)` | 完整 SKILL.md | 1000-5000 |
| Execution | SKILL.md 指 `references/xxx` | 单个 reference 文件 | 视情况 |
**Skill 设计原则**:写 WHY+WHAT,不写 Step 1/2/3。让模型自己规划。description 要明确具体——决定模型能否触发。
### 3.6 Session 与 Task
**Session**(`core/session.py`)= 消息列表 + meta + 落 `messages.json`
**Task**(`core/task.py`)= Session 的上层概念,含 mode / description / status (active/completed/abandoned) / model / reasoning_effort / cwd / created_at / updated_at / tokens_prompt / tokens_completion。落 `state.json`
存储:`workspace/tasks/<task_id>/{state.json, messages.json}`。每轮 `agent.run` 后调 `sync_task_tokens` 把 LLM 累计 tokens 写回。
**懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save``mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。
**REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|<id>]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)。
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
---
## 4. 模型路由
### 默认配置(`config/agent.yaml`)
```yaml
default_model: deepseek_v4.flash
```
设计上的分模式路由(后续要做)思路:
| 模式 | 模型 | 理由 |
|-----|-----|------|
| 通用 / 编码 / PPT / 提案初稿 | flash | flash SWE-Bench 80.6,够用 |
| 复杂 bug / 提案终稿 | pro + reasoning_effort=max | 关键产出值得花 |
| fallback | claude_4_7.opus | V4 不行时手动切 |
### 成本量级
| 任务 | flash | pro-max | Claude 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 框架(早期 LangChain、AutoGPT)失败的核心:给 LLM 太多脚手架,模型升级后这些脚手架成枷锁。
**正确做法**:把 LLM 当一个**会持续变强的同事**对待,告诉它目标,不告诉它步骤。
### 七条具体原则
1. **Prompt 用 WHY+WHAT,不用 HOW** —— 详细教"应该怎么思考"会降智强模型
2. **Skill 渐进披露,不写完整流程** —— 对齐 Anthropic 标准
3. **工具按原子操作切分,不做高级封装** —— 留出组合空间给模型
4. **Model Profile 化,不硬编码** —— 新模型 5 分钟接入
5. **Capability Probing** —— yaml 是手填的,跑探测对账实际行为
6. **版本化 Prompt** —— `prompts/system/active.md` 软链接(尚未做,等真要切版本时再做)
7. **eval 评估** —— 设计阶段曾认为是关键,落地后判断:个人工具 dogfooding 更有效;**已删**
### 借鉴自(简版)
| 来源 | 借鉴 |
|-----|------|
| CoreCoder | 主循环简洁实现 + Edit 唯一匹配约束 |
| Anthropic Agent Skills | SKILL.md + 渐进披露标准 |
| nanobot | Workspace + 任务隔离 |
| smolagents | LiteLLM 做模型层 + CodeAct 范式启发 run_python |
---
## 6. 风险与取舍
### 已知风险
| 风险 | 缓解 |
|-----|------|
| run_python subprocess 沙盒不够强 | 限制工作目录 + 敏感 env 过滤;后续可升级 Docker |
| V4 在某些复杂任务不如 Claude | dogfooding 判断,fallback 手动切 |
| Skill description 不够好 → 触发不准 | 用 Pro 优化 description,实战观察 |
| Long context 退化 | `probe --long-context` 探测可靠 ceiling,不依赖宣称值 |
| `Session.save()` 不原子,异常会留 0 字节文件 | 后续改 tmp + rename(已记 PROGRESS) |
### 取舍说明
**为什么用 Hybrid 范式而不是纯 CodeAgent**:V4 JSON tool call 已稳定;沙盒成本只在需要时付;兼容 thinking 模式。
**为什么用 Anthropic Skill 标准而不是自创**:行业标准已成,跨 SDK 兼容;直接拿 Anthropic 现成 skills repo。
**为什么不做 subagent**:状态管理复杂度爆炸;单 agent + skill 已覆盖 95% 场景。
**为什么不做 Eval Suite**:DESIGN 旧版按团队/产品场景设计;个人单用户场景里,跑两个真实任务的 dogfooding 比造作 case 信号更强,probe 已覆盖健康检查。
---
## 7. Core / Platform 切分(草案,status=design,2026-05-09)
> §1-§6 是 **personal-tool track**;本节是 **platform track**,目标把 core 包成多租户 SaaS。两者共享同一 core 代码,部署形态不同。本节落地前 §1-§6 路线照走,不阻塞 dogfood。
### 7.1 总原则
| | 形态 | 数据归属 | 接口 |
|---|---|---|---|
| Core(自己做) | 独立 **HTTP/SSE service** + sandbox 进程组 | 对话 / 文件 / 产物 / tokens / 用量 | `/v1/*` REST + SSE |
| Platform(团队做) | BFF + Web/Mobile UI + Auth + Billing | 终端用户 / 订阅 / 发票 | 调用 core `/v1/*` |
CLI 也是 core 的一个客户端 —— 走同一 `/v1`,本地起 core 跑 localhost。dogfood 和平台走同一份代码路径,bug 先在自己身上发现。
参考蓝本:**OpenAI Assistants API** 形态(stateful agent service:`/threads /messages /files /runs`)。
### 7.2 资源模型与接口(/v1)
```
POST /v1/tasks 创建 task(mode/desc/model)
GET /v1/tasks 列表
GET /v1/tasks/{id} 详情
PATCH /v1/tasks/{id} 改 mode/desc/status
DELETE /v1/tasks/{id} 删 task(连带 messages/files/产物)
POST /v1/tasks/{id}/files 上传(multipart,入 task_dir/source/)
GET /v1/tasks/{id}/files 列表
GET /v1/tasks/{id}/files/{name} 下载(产物也走这里)
DELETE /v1/tasks/{id}/files/{name}
POST /v1/tasks/{id}/messages 发消息,返回 {run_id}
GET /v1/tasks/{id}/messages 历史
GET /v1/tasks/{id}/runs/{run_id}/events SSE 事件流
POST /v1/tasks/{id}/runs/{run_id}/cancel
GET /v1/skills 可用 skill 列表
GET /v1/models 可用 model profile
POST /v1/probe (admin) 跑 capability probe
GET /v1/usage tokens/cost/quota 状态(by tenant)
```
**SSE 事件格式**:
```json
{"type":"tool_call","run_id":"...","name":"read","args":{...},"ts":"..."}
{"type":"tool_result","run_id":"...","name":"read","preview":"...","truncated":false}
{"type":"text","run_id":"...","delta":"..."} LLM 流式 token
{"type":"usage","run_id":"...","prompt":1234,"completion":567,"cost_usd":0.012}
{"type":"done","run_id":"..."}
```
**版本化**:`/v1` 半年内 minor 向后兼容,major 6 个月 deprecation 窗口。
### 7.3 认证模型
Core 只信平台,**不直接对终端用户**。
```
Authorization: Bearer <platform_api_key> ← 绑死平台租户
X-Tenant-Id: <tenant_uuid> ← 平台 sign 的 JWT claim
X-User-Id: <user_uuid> ← 同上
X-Request-Id: <uuid> ← 跨服务 trace
```
平台对终端用户做 OIDC/Clerk auth,把租户/用户 ID 签进 JWT 给 core 验签 —— 平台**无法伪造租户 ID**。多租户隔离在 core 这一层强制,平台 bug 不会泄露跨租户数据。
### 7.4 存储:Postgres + 本地文件系统
**结构化数据走 Postgres**(service 形态多 worker 必须):
```
tenants(id, name, api_key_hash, plan, created_at, status)
tasks(id, tenant_id, user_id, mode, description, status, model_profile,
tokens_prompt, tokens_completion, cost_usd, created_at, updated_at)
messages(id, task_id, role, content, tool_calls, tool_call_id,
reasoning_content, created_at)
runs(id, task_id, status, started_at, finished_at, error, tokens_p, tokens_c, cost_usd)
files(id, task_id, name, path, size, content_type, uploaded_at, kind)
← kind: source / intermediate / artifact
usage_events(id, tenant_id, task_id, run_id, kind, value, ts)
← 计费/审计明细,append-only
quotas(tenant_id, max_concurrent_runs, max_tokens_month, max_storage_bytes, ...)
```
**文件走本地磁盘**:
```
<storage_root>/
tenants/{tenant_id}/
tasks/{task_id}/
source/ ← 用户上传(PDF / 团队介绍等)
intermediate/← 中间产物(spec_lock.md / sections / slides)
artifact/ ← 最终产物(.docx / .pptx)
```
`files.path` 存相对 `<storage_root>` 的路径。`<storage_root>` 由部署配置决定(单机 = 一个目录,多 worker = 共享挂载点)。
为什么本地而不 S3:简化首版部署,运维门槛低;访问延迟低;tasks 文件总量 100GB 级别本地盘够用。规模真起来后,**files 表 + storage 抽象层只需换 backend,接口不动**。
### 7.5 沙盒:Per-task 容器 + Per-run exec
| 选择 | 理由 |
|---|---|
| 每 task 一个长驻容器 | 起容器 ~300ms 太慢;一个 task 多轮 tool call 共享容器才划算 |
| 每 run 一次 `docker exec` | 进容器跑 run_python/shell;exec 级 timeout/资源限制 |
| 容器空闲 N 分钟回收 | 不浪费;下次 resume 重新拉起 |
| `task_dir` 直接 bind mount 进容器 | 宿主 `<storage_root>/.../tasks/{id}/` 挂到容器 `/workspace`,无同步开销 |
**资源限制**:cgroup CPU/mem 上限、磁盘配额、网络 egress allowlist(只能出 LLM API 和 PyPI 镜像)、root 文件系统 read-only、no-new-privileges、drop ALL caps。
**选型**:Phase 起步用 **Docker**(运维门槛低);流量起来后视情况换 gVisor / Firecracker / e2b。Executor Protocol 抽象后切换成本低。
### 7.6 Core 代码改造(按依赖顺序)
| # | 改造 | 影响文件 | 估时 |
|---|---|---|---|
| 1 | **事件流化 `loop.py`** —— `console.print` 改成 `yield Event`;CLI 接 console sink,API 接 SSE sink | `core/loop.py` `cli.py` | 半天 |
| 2 | **Storage 层** —— `Session/TaskState` 落 Postgres;`files` 走 `<storage_root>` | `core/session.py` `core/task.py` `main.py` 新增 `core/storage/` | 2 天 |
| 3 | **Executor 抽象** —— `run_python`/`shell` 走 `Executor.run(code, timeout, env)`;实现 = `docker exec` 到 per-task 容器 | `tools/run_python.py` `tools/shell.py` 新增 `core/executor/` | 2 天 |
| 4 | **Config + 多租户上下文** —— 每次请求带 `(tenant_id, user_id)`,所有 storage/executor 调用都 scoped | `main.py` `cli.py` 全链路 | 1 天 |
| 5 | **`api_key_env` 退役** —— 改 `KeyProvider`,运行时从 vault 取(平台 BYO 模式则逐请求注入) | `core/capabilities.py` `core/llm.py` | 半天 |
| 6 | **HTTP 外壳** —— FastAPI app,把上面五层包成 `/v1/*` + SSE | 新增 `core/api/` | 3-4 天 |
代码量增量预估:**+800~1200 行**(API 层 + storage 层 + executor 层 + 配套测试)。
### 7.7 职责矩阵
| 事项 | Core | Platform | 备注 |
|---|---|---|---|
| 终端用户 auth | | ✅ | OIDC/Clerk |
| 平台↔core 鉴权 | ✅ 验签 | ✅ 签发 | Bearer + JWT |
| 多租户数据隔离 | ✅ enforce | | platform bug 不能跨租户 |
| Prompt injection 防护 | ✅ | | 只有 core 看得见 LLM I/O |
| 敏感数据出站审计 | ✅ 产事件 | ✅ 消费转 SIEM | 双层 |
| 配额超限拒绝 | ✅ 计数 + 拒绝 | ✅ 展示给用户 | core 不能信平台传值 |
| 文件病毒扫描 | ✅ 入库再扫一次 | ✅ pre-upload 扫 | 双层 |
| GDPR 删除 | ✅ 提供 `DELETE` | ✅ 触发 | 数据在 core 这边 |
| LLM API key(BYO) | 运行时注入,**不持久化明文** | ✅ 加密存,逐请求传 | 降攻击面 |
| 计费 | ✅ 产 usage 事件 | ✅ 汇总 + Stripe | core 不碰钱 |
| 监控 / SLO | ✅ 自己负责 | | 独立服务 |
### 7.8 分阶段落地
| 阶段 | 目标 | 工作量 | 验收 |
|---|---|---|---|
| **A** 改造 §7.6 #1(事件流化) | CLI 不变,但 loop yield event;为后续铺路 | 半天 | CLI 走 event sink 跑通 dogfood |
| **B** 改造 #2-#5(storage / executor / DI / key)| 单进程仍可跑;接口齐备 | 1 周 | CLI 接 Postgres 跑通本地多 task |
| **C** 改造 #6(HTTP 外壳)| `/v1/*` 跑通,docker compose 起完整栈(core api + sandbox + PG) | 4 天 | curl / Postman 跑通主流程 |
| **D** 多租户 + Auth + Quota + Audit | 平台接得上 | 1 周 | 平台 demo 跑通 |
| **E** 上线打磨(限流/监控/告警/HA) | 可承接平台真实流量 | 持续 | SLO 99.5% |
**A** 立刻可做,独立有价值。**B-D** 等平台团队 kickoff 时间锁定后开。**E** 上线后持续投入。
### 7.9 已知风险
| 风险 | 缓解 |
|---|---|
| 过早抽象违背 §5 哲学 | A 阶段单独有价值(支持 Web UI);B-E 等平台到位再做 |
| `/v1` 冻死后核心演化变慢 | minor 向后兼容窗口,major 6 个月 deprecation;内部加 `/v1internal` 用于实验 |
| Sandbox 网络出站限制不当 | Egress allowlist 起步只放 LLM endpoint + PyPI 镜像;skill 需要新出站靠申请 |
| Per-task 容器 ID 泄漏到对话 | tool 结果 sanitize,容器内 hostname/IP 不暴露 |
| 平台 bug 把 core 冲垮 | 平台维度限流(rate limit by Bearer key)+ 租户维度并发上限 |
| 文件计费/存储滥用 | 上传大小上限、月度配额、保留期(过期自动清) |
| LLM 成本失控 | BYO key 默认;平台代付要 per-tenant 月预算硬上限 |
| 本地盘容量瓶颈 | files 表 + storage 抽象层,换 backend 接口不动;LRU 清理冷 task |
| 多 worker 共享本地盘 | 起步单机部署即可;需要扩 worker 时上 NFS 或换对象存储 |
### 7.10 取舍说明
**为什么 Docker 起步而不直接上 Firecracker/gVisor**:Docker 运维门槛最低,Executor Protocol 抽象后可平滑切换。提前上 microVM 是过度工程。
**为什么不复用 K8s Job per run**:Job 启停成本高(秒级),agent loop 一轮 tool call 才几百毫秒。Per-task 长驻容器 + per-run exec 是性能/隔离的最佳折中。
**为什么本地文件而不 S3**:首版部署/运维门槛低,访问延迟低,100GB 级别单机够用。storage 抽象层留好,真要切对象存储改 backend 不改接口。
**为什么 Postgres 而不 SQLite**:service 形态下 API + sandbox + 后续 worker 都要读写状态,SQLite 单写锁会成为瓶颈。Postgres 起步成本可接受。
---
## 附录: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
- 价格:输入 ~$0.145/M,输出 ~$1.74/M(约 Claude Opus 1/6 ~ 1/7)
- `deepseek-chat` / `deepseek-reasoner` 在 2026-07-24 后下线 → 必须迁 `deepseek-v4-flash` / `deepseek-v4-pro`