这份教程教你什么:不是教你"再读一遍科研 Agent 源码",而是教你亲手造一个 OpenAI4S 级产品——面向湿实验生信人与计算化学家的本地科研工作台:原生 JSON 工具管编排与权限,持久 Python/R 内核管科学执行,纯标准库守护进程 + 手写 WebSocket 把结果推回聊天气泡。 最反直觉的一点:你以为要做一个"更聪明的 coding agent"——错了。你要造的是两个动作平面叠在一起的科研运行时;更反直觉的是——内核协议与 provenance 的偏执,比模型选型更能决定产品是否可信:控制平面走可审计的 JSON
Tool调用;科学平面走持久内核里的完整代码 Cell。而且核心引擎、LLM 客户端(urllib)、Web 守护进程(http.server+ 手写 WS)全部零第三方依赖。 怎么教:和本仓库《Open Design 系》《Manus 系》《OpenWorker 系》一样——跟着真实场景,从白纸开始,每撞一堵墙就补一个零件。口吻是 Codex 产品经理 + 下场写代码的开发者:先讲取舍,再讲文件怎么落,再讲怎么验收。 对照源码:基线参考项目/OpenAI4S@ commit85e9fa0(2026-08,Feat/retrosynthesis workflow triage)。每个零件给出文件与关键符号。配套阅读《OpenAI4S-源码分析》。 前置:知道「Agent 循环 = 模型说话 → 软件替它动手 → 结果喂回模型」就够了。你不需要先会蛋白质折叠;但你要愿意在本地起一个守护进程、愿意用 SQLite 当账本。
目录
- 先看终点:一个"科研双平面 Agent"长什么样
- 搭建思维链:你必须先相信的八个命题
- 第 0 步 · PRD:Open AI for Scientist
- 第 1 步 · 从 PRD 落到包布局
- 第 2 步 · 配置与数据目录契约
- 第 3 步 · 多供应商 LLM:
urllib客户端 - 第 4 步 ·
AgentEngine端口与route_action - 第 5 步 ·
HostDispatcher:能力信封 - 第 6 步 · Host SDK 注入内核
- 第 7 步 · 惰性持久内核 + JSON-line 协议 + 沙箱
- 第 7.5 步 · 内核品味实验室:dup2 / 帧上限 / 假真相
- 第 8 步 · Action Ledger + SQLite Store
- 第 9 步 · Native Tool 目录与
finalize_response - 第 10 步 · Skills:代码配方,不是 JSON Schema
- 第 11 步 · Gateway:
http.server+ 手写 WebSocket - 第 12 步 · WebUI 发送路径:composer → 气泡
- 第 13 步 · 权限与审批卡
- 第 14 步 · Artifact 版本化与溯源
- 第 15 步 · 上下文压缩 Compaction
- 第 16 步 · 委派 Delegation
- 第 17 步 · FIFO 执行协调与会话内核所有权
- 第 18 步 · 科学连接器与证据包
- 第 19 步 · 安全分层(纵深防御)
- 第 20 步 · BYOC 远程计算(可选后期)
- 第 21 步 · 打包分发:dmg / pip / WSL(后期)
- 🎬 完整回放:用户输入一段提示词之后如何被打包上传接收反复运作直到返回聊天窗口
- 附录 A · 验收清单
- 附录 B · 最容易翻的车
- 附录 C · 源码对照索引
先看终点:一个"科研双平面 Agent"长什么样
在写第一行代码前,先把要拼的东西看清楚。编程 IDE Agent 和科研 Agent 的分界,不在模型,而在这六件事:
- 动作不是单一工具表——编排走 JSON 控制平面,计算走持久内核科学平面;
- 状态要能留下来——十万行 DataFrame 留在内核内存里,上下文只留一句
"<DataFrame 100000×20>"; - 完成信号是契约——对话/工具回合用 Engine 自有的
finalize_response;科学 Cell 内只有host.submit_output(...); - 账本先于 UI——终止态、工具组、usage 写进 append-only Action Ledger,聊天气泡只是投影;
- 核心零依赖——科学家机器环境脏、防火墙多、装依赖容易炸;stdlib daemon 是产品决策;
- 交付物是证据——版本化 Artifact、环境 provenance、检索 SHA-256,不是一段会过期的聊天记录。
把这六件事画进装配图:
flowchart TB
subgraph UI["① 你能看到的"]
CHAT["聊天气泡 + Activity 卡片"]
NB["只读 Notebook 轨迹"]
ART["版本化 Artifact 面板"]
PERM["权限审批卡"]
end
subgraph GATEWAY["② 守护进程(stdlib)"]
HTTP["http.server REST"]
WS["手写 WebSocket"]
RUN["SessionRunner._loop"]
FIFO["FIFO 执行协调器"]
end
subgraph OUTER["③ 外环 · AgentEngine"]
MODEL["ModelPort · multi-provider LLM"]
ROUTE["route_action"]
TOOLS["NativeToolBatch 执行"]
FINAL["FinalizeAction"]
CELL["CodeCell python/r"]
LEDGER["Action Ledger"]
end
subgraph INNER["④ 内环 · Kernel + host RPC"]
K["Lazy persistent Kernel"]
SDK["sdk/host.py 注入"]
DISP["HostDispatcher"]
STORE["SQLite Store"]
end
subgraph CONTENT["⑤ 可版本化内容"]
SK["skills/*/SKILL.md 配方"]
ENV["envs/*.yml 内核环境"]
WSFS["session workspace 文件"]
end
CHAT --> HTTP
HTTP --> RUN
RUN --> MODEL
MODEL --> ROUTE
ROUTE -->|原生 JSON| TOOLS
ROUTE -->|sole finalize| FINAL
ROUTE -->|一个完整 Cell| CELL
TOOLS --> DISP
CELL --> K
K <-->|"host_call/ack/response"| SDK
SDK --> DISP
DISP --> STORE
DISP --> SK
K --> WSFS
RUN --> LEDGER
LEDGER --> STORE
RUN --> WS --> CHAT
WSFS --> ART
DISP --> PERM
K --> NB
FIFO --> K
记住这张图。下面每一步都是往里填一个方块。
和 Open Design 路线的关键分叉:Open Design 不写 Agent 主循环(把已装好的 CLI 当引擎);OpenAI4S 必须写主循环——因为科学执行要持久命名空间、同步 mid-cell Host RPC、账本与 Artifact 契约,这些是"借一个 coding CLI"给不了的。
搭建思维链:你必须先相信的八个命题
产品经理视角:在写 PRD 条款之前,先决定世界观。OpenAI4S 的源码不是从「我们要有聊天框」长出来的,而是从下面八条命题反推出来的。你若先不信这些,后面 22 步会不断返工。
flowchart TB
T1["① 科学状态必须跨回合存活"] --> T2["② print 绝不能污染协议"]
T2 --> T3["③ Cell 中途必须能同步回调 Host"]
T3 --> T4["④ 编排与计算不能抢同一回合"]
T4 --> T5["⑤ 「完成」必须是结构化事实"]
T5 --> T6["⑥ UI 气泡不是真相源"]
T6 --> T7["⑦ 假 provenance / 假绿不可接受"]
T7 --> T8["⑧ 核心零依赖 = 信任边界"]
| # | 命题 | 若你不信,会做出的错误产品 | 对应模块 |
|---|---|---|---|
| ① | DataFrame / 拟合对象要留在内核 | 每步 tool 来回传 CSV,上下文爆、结果不可复现 | 第 7 步 LazyKernel |
| ② | 协议线与用户 stdout 必须物理分家 | Notebook「偶发卡死」、帧错位、RPC 读到 print | 第 7 / 7.5 步 dup2 |
| ③ | 科学循环需要 mid-cell host.* |
退回 ReAct,14 次往返干一次筛选+作图 | 第 5–6 步 Host |
| ④ | JSON Tool 与 Code Cell 互斥优先 | 同回合又改元数据又跑模拟,账本不可读 | 第 4 步 route_action |
| ⑤ | finalize ≠ submit_output | 「模型说完了」但没有 metrics/产物版本 | 第 9 步 |
| ⑥ | Ledger/Store 先于气泡 | 刷新丢状态;Stop 语义漂;分享包不可信 | 第 8、12 步 |
| ⑦ | 错误的指纹比没有更糟 | R 产物盖上 Python 包列表,论文附件说谎 | 第 7.5、14 步 |
| ⑧ | 核心 stdlib-only | 一引入框架,沙箱/审计/打包叙事全糊 | 第 1–3、11 步 |
Codex PM 检查句:评审任何新需求时问——「它增加的是可辩护的科学事实,还是只是又一个聊天特效?」过不了这句的需求,默认延后。
第 0 步 · PRD:Open AI for Scientist
0.1 一句话产品
Open AI for Scientist(OpenAI4S):跑在科学家自己电脑上的混合式科研智能体工作台——用便宜的豆包/方舟 API(¥9.9/月档也能用),完成从文献/数据库检索、数据分析、可视化到可复现证据包的闭环;不是又一个编程 IDE Agent,也不是多租户 SaaS。
0.2 Persona
| Persona | 日常痛点 | 成功那天长什么样 |
|---|---|---|
| 湿实验生信人(硕士/博后,会一点 R/Python,主要在湿台) | 测序回来不会清理;想画火山图却卡在依赖;怕把原始数据弄丢 | 把 count matrix 丢进工作区,用中文说目标,得到带版本号的图 + 可复现 notebook 轨迹 + 环境指纹 |
| 计算化学家(会 RDKit/对接,本地或集群有 GPU) | 工具链散落;远程 GPU 脚本每次手写;结果对不上哪次环境 | 本地编排 + 可选 BYOC 投 GPU;Artifact 记下 generation_id 与输入 lineage |
0.3 Jobs-to-be-done
- 检索并落地证据:查 UniProt / RCSB / PubChem / arXiv,结果要可哈希、可归档。
- 在持久环境里算:多步 pandas/scanpy/RDKit 分析不因"每步重新开进程"而崩。
- 留下可审计轨迹:每一步工具/Cell 进账本;危险操作弹审批卡。
- 交付可分享包:导出 session / 证据包给合作者,而不是截图聊天。
- 用得起:默认对接火山方舟
ark,¥9.9 Small 套餐可跑通主路径。
0.4 Non-goals(写进 PRD 才能挡住 scope creep)
| 明确不做 | 为什么 |
|---|---|
| 不是 coding IDE Agent(不做仓库级重构、PR review、多文件工程导航的主战场) | 用户工作对象是数据与实验,不是 git monorepo |
| 不是多租户 SaaS | 数据敏感;信任边界是"本机单用户 + loopback";多租户会推翻沙箱与密钥模型 |
| 不做 Windows 原生内核 | 内核依赖 POSIX 子进程、fd3/fd4、Seatbelt/bwrap;Windows 走 WSL2 同一 Linux 构建 |
| 不做"无审批自动 rm -rf" | 无头默认 deny;科学用户宁可多点一次,也不要静默毁数据 |
| MVP 不做 Modal/SLURM 一等公民 | BYOC 先 SSH/NIM;集群调度是 v0.2+ |
0.5 成功指标
| 指标 | MVP 门槛 | v0.1 目标 |
|---|---|---|
| 冷启动到可聊 | ./setup.sh && ./start.sh 后 60s 内打开 127.0.0.1:8760 |
dmg 一键同体验 |
| 无第三方核心依赖 | openai4s 引擎 import 图无硬依赖 |
CI 守门 |
| 端到端科学回合 | openai4s run "算均值并 submit" 离线可 mock |
浏览器冒烟绿 |
| 成本可达 | 文档写清 ark ¥9.9 路径 | UI 默认引导 ark |
| 安全姿势 | bind 127.0.0.1;无头 deny |
sandbox auto 可见降级 |
| 复现 | Artifact 带 env provenance | 导出 session package |
0.6 约束
- 模型:优先火山方舟(豆包等),亦支持 OpenAI / Anthropic / Gemini 线;密钥永不进仓库。
- 平台:macOS(Apple Silicon 为一等)+ Linux x86_64;Windows = WSL2。
- 运行时:Python 3.10+;核心纯 stdlib;科学栈进内核环境,不进控制面 venv。
- 数据根:
~/.openai4s(可用OPENAI4S_DATA_DIR改)。 - 合规语气:生物安全筛查默认开;遥测默认关且需同意。
0.7 MVP vs v0.1
| 能力 | MVP(能卖故事) | v0.1(能发给实验室) |
|---|---|---|
| 双平面路由 + finalize/submit | ✅ | ✅ |
| 持久 Python 内核 + host RPC | ✅ | ✅ + R 通道 |
| SQLite Ledger + 消息投影 | ✅ | ✅ + 分支/恢复 |
| 静态 WebUI + WS 流式 | ✅ | ✅ + Timeline/Notebook |
| 权限卡 | 基础 ask/allow | 持久规则 + restart-safe |
| Skills | 3–5 个配方 | 30+ 内置 |
| BYOC | ❌ | Prototype(SSH/NIM) |
| 打包 | pip 可装 | dmg / linux tarball / WSL zip |
0.8 这一句话里藏着的墙
我们的首条用户故事:
"帮我查人胰岛素 INS,从 UniProt/RCSB 拉序列和结构,画一张简单图,把结果存成可复现产物,用中文总结。"
| 用户说的 | 撞上的墙 | 在哪一步补 |
|---|---|---|
| "帮我查" | 谁推理?多供应商怎么统一? | 第 3–4 步 |
| (要不要对话就完成?) | 完成信号是什么? | 第 4、9 步 |
| "拉序列和结构" | 科学 API + SSRF/egress | 第 9、18、19 步 |
| "画图 / 存产物" | 持久内核 + Artifact 版本 | 第 7、14 步 |
| (十万行别塞进 prompt) | Code-as-Action + compaction | 第 7、15 步 |
| (危险写文件) | 权限卡 | 第 13 步 |
| (daemon 挂了) | Ledger 可重建历史 | 第 8 步 |
| "用中文总结" | WebUI 流式气泡投影 | 第 11–12 步 |
| (以后要上 GPU) | BYOC 边界 | 第 20 步 |
| (给同事安装) | 打包 | 第 21 步 |
0.9 把 PRD 写成可执行的里程碑(建议排期)
| 周 | 交付 | 对应步骤 |
|---|---|---|
| W1 | 包布局 + Config + 假 LLM transport 的 Engine 单测绿 | 1–4 |
| W2 | HostDispatcher + 内存 Store stub + openai4s run 能 finalize |
5–6、9 |
| W3 | 真 Kernel + host RPC + submit_output 端到端 | 6–7 |
| W4 | SQLite Ledger + 迁移 + 崩溃夹具 | 8 |
| W5 | gateway + 静态 WebUI + WS 流式气泡 | 11–12 |
| W6 | 权限卡 + Artifact 版本 + 基础 science_search | 13–14、18 |
| W7 | Compaction + Delegation + FIFO 硬化 | 15–17 |
| W8 | 安全层默认姿势 + doctor | 19 |
| 后期 | BYOC、dmg/pip、R 通道打磨 | 20–21 |
0.10 产品原则(写代码时用来否决需求)
- 账本是真相,UI 是投影。 任何"只改前端就当完成了"的 PR 直接打回。
- 控制面与科学面不要竞赛。 同一步只走一个通道。
- 错误的 provenance 比缺失更糟。 不知道就写明 missing reason。
- 无订阅者不得默认允许。 安全事件不能因为"没人看卡片"而放行。
- 核心零依赖是约束不是风格。 想加
httpx/fastapi先写清楚为什么不能 urllib/http.server。
✅ 本步验收:你能向投资人/实验室 PI 用 90 秒讲清:两个平面、stdlib daemon、非 IDE、非 SaaS、¥9.9、macOS+Linux。
第 1 步 · 从 PRD 落到包布局
目的
把 PRD 里的边界变成目录所有权。OpenAI4S 后期翻车,80% 是"新能力塞错包":把 GPU provider 写进 skills/、把 SQL 写进 gateway.py、把完成契约注册成普通 Tool。
实现形式
目标树(对照 85e9fa0):
openai4s/
agent/ # 外环:engine、ports、actions、ledger、compaction、delegation
kernel/ # 内环:manager、worker、lazy、r_kernel、sandbox 协作
sdk/ # 注入到 worker 的 host 门面
host/ # 能力实现(files/llm/delegation/…)
host_dispatch.py
llm/ # urllib 多供应商
tools/ # Native JSON Tool 子类 + registry
store.py # SQLite 门面
storage/ # repositories + migrations
server/ # gateway、SessionRunner、webui/
skills_loader/ # 配方加载
compute/ # BYOC 宿主侧(后期)
security/ # 分层策略
config.py prompts.py …
skills/ # 内置配方(数据,不是引擎)
envs/ # conda/micromamba 环境定义
openai4s_compute_provider/ # 远程侧 stdlib worker SDK
技术栈
- Python 3.10+ 包布局 +
pyproject.toml/ setuptools - 禁止在核心包加硬第三方 import(numpy 等只允许内核侧或
try/except ImportError)
功能定义取舍
| 做 | 不做 |
|---|---|
| 按信任边界与生命周期分目录 | 按"层"(controller/service/repo)教条切到看不懂 |
兼容门面(gateway.py/store.py/host_dispatch.py)保持薄 |
在门面里堆新算法 |
| Skills 当数据树随包发布 | 把 Skills 当可热加载任意代码插件市场(MVP) |
优劣分析
- 优:CI 与评审有"东西该放哪"的答案;mypy 可只严守
agent/{engine,ports,…}+host_dispatch.py。 - 劣:新人目录多;需要双语 README 守门(OpenAI4S 就是这么干的)。
如何与前后模块配合
- 前:PRD 的 non-goals 直接决定"没有
ide/、没有saas/"。 - 后:第 2 步配置、第 4 步引擎、第 11 步 server 都挂在这棵树上。
flowchart LR
PRD["PRD 边界"] --> PKG["包所有权表"]
PKG --> AGENT["agent 外环"]
PKG --> KER["kernel 内环"]
PKG --> TOOLS["tools 控制面"]
PKG --> SRV["server 投影"]
PKG --> DATA["skills/envs 数据"]
1.4 第一周最小可运行骨架(建议直接建这些文件)
pyproject.toml
openai4s/__init__.py
openai4s/__main__.py
openai4s/config.py
openai4s/agent/{__init__,ports,models,events,actions,engine}.py
openai4s/llm/{__init__,client,transport,registry}.py
openai4s/tools/{__init__,base,registry}.py
openai4s/host_dispatch.py
openai4s/sdk/host.py
openai4s/kernel/{manager,worker,lazy}.py
openai4s/store.py
openai4s/server/{gateway.py,webui/index.html,webui/app.js,webui/style.css}
tests/test_agent_route_action.py
先让 python -m openai4s 打印版本;再让 AgentEngine 在 FakeModel 下跑通 finalize。不要一上来写 WebUI 美化。
1.5 所有权表示例(贴墙上)
| 变更 | 正确位置 | 错误位置 |
|---|---|---|
| 新控制工具 | tools/foo.py + TOOL_TYPES |
gateway.py 内联 |
| 新 Host 能力算法 | host/foo.py |
host_dispatch.py 堆逻辑 |
| 新 SQL | storage/foo.py + migration |
store.py 复制 SQL |
| 新 WS 事件投影 | server/*_service.py |
只改 app.js 假装后端有了 |
| 新科学流程 | skills/my-flow/SKILL.md |
硬编码进 system prompt 三千行 |
| 新 GPU 后端 | compute/ + remote-compute-* |
随便一个 skill 里 shell 出去 |
✅ 本步验收:画一张"新 Tool / 新 Host 能力 / 新 SQL / 新 UI 路由"分别落点的表;随便指出一个错误落点并说明后果。
第 2 步 · 配置与数据目录契约
目的
在写 LLM 之前,先定:密钥从哪来、数据写哪、进程绑哪个口。科学用户会从 UI 配模型,也会用 .env;两者不能互相踩。
实现形式
openai4s/config.py:Config/LLMConfigdataclass + 零依赖.env加载- 环境变量分层:
OPENAI4S_<PROVIDER>_API_KEY→OPENAI4S_LLM_API_KEY→ provider 默认 - 数据根:
~/.openai4s/{logs,artifacts,tool-results,compaction-history,openai4s.db,agent-workspaces,…} - 端口:
OPENAI4S_HOST=127.0.0.1,OPENAI4S_PORT=8760
技术栈
dataclasses+os.environ+ 手写 dotenv(不引python-dotenv)
功能定义取舍
| 做 | 不做 |
|---|---|
占位符 Key(your-api-key-here)当未配置 |
把示例 Key 当已配置导致"假登录" |
| daemon 可无 Key 启动,UI 再配 | 强制启动时交互输入(打断 start.sh) |
数据目录 0700、数据库 0600 |
世界可读的"方便分享目录" |
优劣分析
- 优:方舟/Claude/Gemini 密钥可共存;测试可把
OPENAI4S_DATA_DIR指到 tmp。 - 劣:分层解析调试时要打印"到底命中哪一层"。
如何与前后模块配合
- 后接 LLM client(第 3 步)与 Store 路径(第 8 步);Gateway 读同一
Config。
flowchart TD
ENV["真实环境变量"] --> RES["LLMConfig 解析"]
DOT[".env 不覆盖已存在"] --> RES
RES --> KEY["api_key / base_url / model"]
RES --> CFG["Config.data_dir / host / port"]
CFG --> DB["db_path"]
CFG --> WS["agent-workspaces"]
✅ 本步验收:只设
OPENAI4S_ARK_API_KEY时provider=ark能读到;模板占位符被判定为无 Key。
第 3 步 · 多供应商 LLM:`urllib` 客户端
目的
科学家的 Key 可能是方舟、DeepSeek、官方 Claude/GPT/Gemini。产品要一行切换供应商,但不能为每个 SDK 引入依赖——核心必须继续纯 stdlib。
实现形式
openai4s/llm/
client.py # chat() 编排入口
transport.py # urllib 的 post_json / post_sse
registry.py # PROVIDERS 目录(wire/model/base_url)
providers/
openai.py # Chat Completions
responses.py # OpenAI Responses
anthropic.py # Messages
gemini.py # generateContent
tooling.py messages.py capabilities.py catalog.py
对外兼容门面:from openai4s.llm import chat。
技术栈
urllib.request+ SSE 行解析- 统一归一化:
ModelReply/ tool_calls(保留 raw_arguments + parse_error + provider_meta)
功能定义取舍
| 做 | 不做 |
|---|---|
四种 wire + ark 作为 OpenAI 兼容入口 |
为每个模型厂装官方 SDK |
| capability 层(vision / tool_calling / parallel) | 假设所有端点都支持 tools |
| 无 tool_calling 时退回 Code-as-Action 路径 | 硬发 schema 导致整回合失败 |
流式 on_delta |
MVP 就上复杂的多模态编辑器 |
优劣分析
- 优:打包体积小;离线测试可整段 mock transport;方舟 ¥9.9 路径零摩擦。
- 劣:SSE/错误码要自己跟各厂文档对齐;严格 schema 差异要 capability 缓冲。
如何与前后模块配合
- 前:Config 解析 Key。
- 后:
AgentEngine的ModelPort包一层ChatModel;Host 的host.llm也走同一chat()。
sequenceDiagram
participant E as AgentEngine
participant C as llm.chat
participant T as transport.urllib
participant P as Provider API
E->>C: messages + tools + on_delta
C->>C: provider_spec + capabilities
C->>T: post_sse / post_json
T->>P: HTTPS
P-->>T: SSE chunks
T-->>C: deltas + final
C-->>E: ModelReply(tool_calls|content)
3.4 最小 chat() 形状(你要先冻结的契约)
# 教学简化版 —— 生产代码见 openai4s/llm/client.py
def chat(messages, cfg, *, on_delta=None, tools=None, post_sse, post_json):
spec = provider_spec(cfg.provider)
wire = spec["wire"] # openai | responses | anthropic | gemini
model = cfg.model or spec["model"]
base = cfg.base_url or spec["base_url"]
# 1) capability 门闩 2) 译 tool specs 3) 调对应 wire
return wire_dispatch[wire](...)
返回值必须能被 ModelReply.from_mapping 吃下,至少包含:
content: strtool_calls: list[{id, wire_id, name, ordinal, raw_arguments, arguments, parse_error, provider_meta}]usage: {prompt_tokens, completion_tokens}
取舍:raw_arguments 与 parse_error 一并保留——路由层是无损边界,不是第二个 JSON 解析器。解析失败也要进 Ledger,不能静默丢。
3.5 为什么方舟走 OpenAI 兼容 wire
火山方舟 Agent Plan 暴露的是 OpenAI Chat 兼容端点。你把 provider=ark 编进 registry,复用 providers/openai.py,只改默认 base_url/model 目录。这样 ¥9.9 路径不需要第三套协议实现。
✅ 本步验收:对 fake transport 断言:ark 与 anthropic 都能产出同一形状的
tool_calls;无 Key 时错误信息指向正确环境变量名。
第 4 步 · `AgentEngine` 端口与 `route_action`
目的
写出可单测、不依赖内核/服务器的外环状态机。这是整条产品的脊柱:CLI openai4s run 与 Web SessionRunner._loop 必须共用,否则两个循环会漂移。
实现形式
agent/ports.py:ModelPort/ActionExecutor/ContextPolicy/EventSink/CancellationPort/CompletionPort/ReplyInterceptoragent/engine.py:AgentEngine.run回合循环agent/actions.py:route_action、CodeCell、NativeToolBatch、FinalizeActionagent/models.py:RunState/ModelReply/ExecutionOutcome/EngineResultagent/events.py:RunStarted…RunFinished
核心循环(语义压缩版):
while turn < max_turns:
prepare context
reply = model.complete(messages, on_delta)
action = route_action(reply.content, reply.tool_calls)
outcome = executor.execute(action, reply, state)
append history
if outcome.stop_reason or completion: break
route_action 铁律:
- 唯一原生调用且名为
finalize_response→FinalizeAction - 否则只要有原生调用 →
NativeToolBatch(整批,控制面优先) - 否则从正文抽第一个完整
python /r Cell - 都没有 →
None(上层 nudge)
技术栈
- 纯 typing Protocol + dataclass;mypy strict 守这几份文件
功能定义取舍
| 做 | 不做 |
|---|---|
| 每步最多一个动作通道 | 同回合并行"工具 + Cell" |
| finalize 不进 Tool 注册表 | 让插件替换完成契约 |
| 普通散文 ≠ 完成 | 把最后一条 assistant 文本当 done |
多 Cell 只跑第一个并附 MULTI_CELL_NOTE |
默默执行全部 Cell |
优劣分析
- 优:引擎可在无网络、无子进程下测路由与终止态;Web/CLI 只换适配器。
- 劣:模型若"边说边写两个 Cell"会被裁,需要提示词与 nudge 教育。
如何与前后模块配合
- 前:LLM
ModelReply。 - 后:Executor 在 CLI 由
agent/runtime.py装配,在 Web 由WebActionExecutor装配;真正干活进 Host/Kernel。
flowchart TD
R["ModelReply"] --> RA{"route_action"}
RA -->|"sole finalize_response"| F["FinalizeAction"]
RA -->|"其他 native calls"| B["NativeToolBatch"]
RA -->|"无 native"| X{"extract_action"}
X -->|python/r fence| C["CodeCell"]
X -->|无| N["None → nudge"]
F --> EX["ActionExecutor"]
B --> EX
C --> EX
N --> EX
4.4 FakeModel 驱动的第一份测试(建议原文落地)
class FakeModel:
def __init__(self, replies):
self.replies = list(replies)
def complete(self, messages, on_delta):
reply = self.replies.pop(0)
if on_delta and reply.get("content"):
on_delta(reply["content"])
return reply
class FakeExecutor:
def execute(self, action, reply, state):
# 断言 action 类型,然后返回 ExecutionOutcome(stop_reason=...)
...
先测:
- 唯一
finalize_response→ 停止原因为完成; web_search+ 正文里的 ```python → 走 batch,不跑代码;- 正文两个 python Cell → 只抽第一个;
- 未闭合 ```python → 不执行 + nudge。
等这四条绿,再接真 HTTP。
4.5 单回合在引擎内的微观时序
sequenceDiagram
participant P as ContextPolicy
participant M as ModelPort
participant R as route_action
participant X as ActionExecutor
participant S as EventSink
P->>P: prepare(messages)
M->>S: TextDelta*
M->>R: ModelReply
R->>X: Action|None
X->>S: OutcomeProduced
alt stop/completion
X-->>P: EngineResult
else continue
X->>P: history 追加,下一 turn
end
✅ 本步验收:纯单元测试覆盖:finalize 优先、工具压过代码、多 Cell 只取第一、未闭合围栏不执行。
第 5 步 · `HostDispatcher`:能力信封
目的
无论调用从哪来——原生 Tool、或 Cell 内 host.web_search——都必须穿过同一道权限 / 审批 / 审计 / 注入筛查 / 活动事件信封。否则你会拥有两套互不一致的安全故事。
实现形式
openai4s/host_dispatch.py:HostDispatcher.__call__(method, args) -> dataopenai4s/host/*.py:WorkspaceFileService、LLMService、DelegationService、CompletionService、SkillService…- soft-fail:
{"error": msg}→ worker 转RuntimeError
技术栈
- 线程锁 + 可选
ThreadPoolExecutor(只读工具并行波次在控制执行器侧) - 与
tools.registry.get_tool_by_host_method协作
功能定义取舍
| 做 | 不做 |
|---|---|
| Dispatcher 当信封,算法下沉 host/ | 5000 行上帝类实现所有科学逻辑 |
| 可见方法投影为 Activity step | 把 host.llm 内部调用刷进 Timeline |
插件 register_tool 泛型解析 |
为每个新工具手写 _m_* 分支(历史兼容可留薄适配) |
优劣分析
- 优:审计与权限单点;Web 控制工具与内核 RPC 同策略。
- 劣:门面仍大,重构必须"外科手术",禁止整文件重写。
如何与前后模块配合
- 后:SDK 只发
host_call;Kernel manager 调 Dispatcher;Ledger 记审计。
flowchart LR
A["Tool.invoke / host.*"] --> D["HostDispatcher"]
D --> P["permission / approval"]
D --> I["injection screen"]
D --> S["host service"]
D --> L["audit / step event"]
S --> OUT["data | {error}"]
✅ 本步验收:同一
web_fetch从 Tool 与host.web_fetch进入时,审批与 audit 字段一致。
第 6 步 · Host SDK 注入内核
目的
让科学代码在 Cell 执行中途同步调用宿主能力:host.llm、host.delegate、host.save_artifact……这是 tool_use 架构没有的内环。
实现形式
openai4s/sdk/host.py:worker 内host单例门面- 启动 worker 时注入
host_call(method, args)回调 - 编解码:SDK snake_case ↔ wire camelCase;
None字段省略(避免 JSON null 撞严格校验) host.bash:不在 Host 执行 shell;Host 只签发与 command hash / cwd / generation / challenge 绑定的一次性 token,worker 本地执行
技术栈
- 纯 Python 门面;无第三方
HOST_CAPABILITY_VERSION写入 bootstrap manifest,供恢复比对
功能定义取舍
| 做 | 不做 |
|---|---|
| Python 内环 RPC | 给 R 做同等 mid-cell Host RPC(R 是独立分析通道) |
分析内核剥离 delegate/compute/... 符号 |
运行时 if 拒绝(符号存在却抛业务错更易误导) |
host.submit_output 作为 Cell 内唯一完成 |
注册 submit_output 为 JSON Tool |
优劣分析
- 优:科学家用 for/if/库组合能力;大对象不进 prompt。
- 劣:死锁风险——必须单飞行
host_call锁 + 单 reader 循环。
如何与前后模块配合
- 前:Dispatcher。
- 后:Kernel 协议帧
host_call/host_ack/host_response。
sequenceDiagram
participant Cell as Python Cell
participant SDK as host SDK
participant W as worker.py
participant M as Kernel manager
participant H as HostDispatcher
Cell->>SDK: host.web_search(...)
SDK->>W: host_call frame
W->>M: stdout protocol line
M->>H: dispatch(method,args)
H-->>M: data
M->>W: host_response
W-->>SDK: return
SDK-->>Cell: result
✅ 本步验收:在测试内核里执行
host.submit_output({"ok": True}, completion_bullets=["done"]),外环 stop_reason=submitted。
第 7 步 · 惰性持久内核 + JSON-line 协议 + 沙箱
目的
科学平面落地:Tool/Finalize-only 回合不起 worker;第一个 Cell 才惰性拉起;命名空间跨 Cell 持久;stdout 与协议分家;OS 沙箱约束写路径与裸网。
实现形式
kernel/manager.py:Kernel宿主侧,spawn + 单线程读帧kernel/worker.py:Python worker;compile(..., "<kernel:N>")+ linecache;getrusage记账kernel/lazy.py:CLI 一次性惰性所有权kernel/r_kernel.py+r_worker.R:同协议;协议走 fd3/fd4,杂讯进 stderrkernel/environment.py:子进程环境 allowlist 重建(不继承 daemon 密钥)security/sandbox.py:macOS Seatbelt / Linux bubblewrap;auto|enforce|off
协议(概念):
{"type":"execute","id":"...","code":"..."}
{"type":"host_call","id":"...","method":"web_search","args":[...]}
{"type":"host_ack","id":"..."}
{"type":"host_response","id":"...","data":{...}}
{"type":"result","id":"...","ok":true,"stdout":"..."}
技术栈
subprocess+ JSON lines- POSIX 重定向(R);Seatbelt / bwrap
功能定义取舍
| 做 | 不做 |
|---|---|
| 惰性启动 | 会话一开始就预热两个重型内核(浪费) |
| 持久命名空间 | 每 Cell 新进程(ReAct 式,杀科学体验) |
| stdout 捕获与协议分离 | 让 print 破坏帧同步 |
| Windows 原生内核 | 直接拒绝并引导 WSL2 |
优劣分析
- 优:Code-as-Action 把 14 次工具往返收成 1 个 Cell;内存态可复用。
- 劣:崩溃恢复、generation ABA、watchdog 都要认真做(见第 17 步)。
如何与前后模块配合
- Artifact provenance 绑定 kernel generation,不是 daemon 进程包列表。
- 安全层(第 19 步)叠在 spawn 边界。
flowchart TB
T["Tool/Finalize 回合"] -->|不 spawn| Z["无 worker"]
C["首个 Python Cell"] --> L["Lazy start python slot"]
R["首个 R Cell"] --> LR["Lazy start R slot"]
L --> SB["sandbox wrap"]
SB --> W["worker subprocess"]
W --> NS["持久 globals"]
W --> RPC["mid-cell host RPC"]
7.4 协议实现时的三条铁律
- 单 reader:只有
Kernel管理线程读 worker stdout;supervisor/watchdog 不准偷读。 - 单飞行 host_call:worker 持锁完成
host_call → host_response;并发 RPC 会帧交错。 - generation 单调:每次 respawn bump generation;token/lease/审批绑定旧 generation 必须失效。
7.5 最小 worker 伪代码
# worker.py 概念版
for line in sys.stdin:
msg = json.loads(line)
if msg["type"] == "execute":
# 重定向 stdout 到捕获缓冲,协议帧走另一条通道
try:
exec(compile(msg["code"], f"<kernel:{n}>", "exec"), ns)
send({"type": "result", "id": msg["id"], "ok": True, "stdout": buf})
except Exception as e:
send({"type": "result", "id": msg["id"], "ok": False, "error": ...})
Host SDK 的 host_call 必须走与 stdout 捕获分离的通道,否则 print 与 RPC 互砸。
✅ 本步验收:连续两 Cell,第二 Cell 能读到第一 Cell 的变量;纯 finalize 回合进程列表无 worker。
⚠️ 还没完:协议抗污染与 provenance 品味见 第 7.5 步;那是本教程与「普通 Agent 教程」分道扬镳的地方。
第 7.5 步 · 内核品味实验室:dup2 / 帧上限 / 假真相
目的
第 7 步让内核「能跑」。本步让你理解作者为什么把 worker 写成那样——这是 OpenAI4S 品味的物理落点。对照源码:kernel/worker.py 文件头、manager.py 单 reader、CLAUDE.md「错误的 provenance 比没有更糟」。
实验室 A · 协议分家(必做思想实验)
假设你偷懒:协议和 print 共用 fd1。
- 模型 Cell 里
print(huge_df); - 同时
host.web_search(...)发host_call; - manager 的
readline()读到半截表格文本 → JSON 崩 → 整会话卡死。
OpenAI4S 的解法:
- Python:
dup2(2,1)+ 高位协议 fd 发布到sys._openai4s_protocol_*(每次调用重新 resolve,禁止缓存); - R:协议走 fd3/fd4,杂讯进 stderr。
取舍:协议实现变「丑」;产品行为变「钝感正确」。
实验室 B · 帧上限不是拍脑袋
阅读 worker.py 里 _MAX_FRAME_BYTES 注释链:
- 先有字符上限
MAX_OUTPUT; - 再发现 UTF-8 /
\uXXXX/ emoji 代理对让「字节」远大于「字符」; - 常量从错误的 6 修到 12 字节/字符最坏情形,并由测试钉住。
品味课:科学 Agent 的用户会打中文、会打分子式、会打 emoji 注释。上限必须按最坏字符推导,否则你会在「看起来遵守了文档」时丢掉 error_lineno 与 usage——又是「错误比没有更糟」。
实验室 C · 单飞行 RPC 与 generation
flowchart TB
A["Cell 调 host.llm"] --> L["获取 _HOST_CALL_LOCK"]
L --> S["发 host_call"]
S --> W["等待 id 匹配的 host_response"]
W --> U["释放锁"]
U --> N["Cell 继续"]
R["worker respawn"] --> G["generation++"]
G --> X["旧 bash one-shot token 全部失效"]
验收思维:若允许并发 host_call 无锁,你省下的是延迟,买下的是偶现错绑结果——科研场景不可接受。
实验室 D · provenance 绑定 generation
错误实现:capture_environment() 冻 daemon 的 pip freeze。
正确实现:查「写出该 Artifact 的 language slot 的 generation_id」再冻那个解释器的包集;若读不到,记缺失原因。
产品文案:UI 上写「环境未知(原因)」远好过写「环境 = 一串不属于它的包」。
功能定义取舍(本步专表)
| 做 | 不做 |
|---|---|
| 协议抗污染、抗巨帧、抗 fd 漂移 | 为了「实现简洁」共用 stdout |
| 注释写清失败史与推导 | 静默魔法常量 |
| generation 绑定安全令牌 | 重启后继续用旧 token |
| matplotlib 由 gateway 捕图 | worker 里 autoclose 导致图消失 |
如何与前后模块配合
- 第 5–6 步 Host 的软失败契约,依赖本步把
{"error"}变成 Cell 内异常。 - 第 14 步 Artifact,依赖本步 generation 指纹。
- 第 19 步沙箱,叠在 spawn 外包;不能替代协议分家。
✅ 本步验收:能讲清「为什么 R 用 fd3/fd4」;能讲清「为什么帧上限用 12」;能讲清「为什么 R 产物不能盖 Python freeze」。讲不清就还没读懂内核。
第 8 步 · Action Ledger + SQLite Store
目的
账本先于 UI:工具声明与结果、Cell 尝试、终止事实、usage、审批决议都要可重建。daemon 重启后,不能靠"浏览器还记得什么"。
实现形式
store.py:单连接、RLock、schema/migrations、只读查询护栏、兼容门面storage/:frame / artifact / action ledger / approvals / settings / memories… repositoriesagent/ledger.py:把AgentEvent写成不可变 group/event;reducer 在崩溃后补齐半开工具组
关键不变量:
- 原生 assistant 声明 + 其全部 tool results 是原子组(compaction 也不能拆)
- 终止态 append,不从聊天气泡反推
host.query只读且 denylist 掉 settings/permissions/ledger 等敏感表PRAGMA user_version+schema_migrations;迁移单事务,非 WAL(审计库选择)
技术栈
- stdlib
sqlite3 - 显式迁移地图
Store._migrate
功能定义取舍
| 做 | 不做 |
|---|---|
| append-only 语义组 | 用可变"当前状态行"冒充历史 |
| 重启后审批卡可点,但不重放旧参数 | 把审批 payload 当执行参数重放 |
| Store.close 幂等且只驱逐自己 | 全局单例死锁下一代连接 |
优劣分析
- 优:Timeline、费用、合规审计同源;测试可断言轨迹。
- 劣:要同时维护投影(messages)与真相(ledger),费脑子。
如何与前后模块配合
- Engine 事件 → Ledger writer;Gateway 投影 assistant 可见文本;恢复流程读 journal。
flowchart TB
E["AgentEvent"] --> W["RuntimeActionLedger"]
W --> G["action_groups"]
W --> V["action_events"]
G --> R["reducer → provider-safe history"]
R --> M["下次 model.complete"]
UI["聊天气泡"] -.->|仅投影| G
8.4 迁移纪律(抄进团队约定)
- 新版本 = 新 step 函数 +
SCHEMA_VERSION += 1 - step 禁止自己
commit;外层单事务 - 先 integrity_check,再 backup API,再 migrate;失败留备份
- 回填必须
WHERE幂等 - 不要轻率改
journal_mode/synchronous:这是审计库,不是高 QPS 业务库
8.5 host.query denylist 心智模型
Agent 可以"看自己的数据库"——这很强大,也很危险。denylist 至少盖住:settings、权限表、原始 ledger、recovery journal、credentials 相关。剥离字面量后再匹配,避免 SELECT 'settings' 误杀,但 "settings" 标识符仍要拦。
✅ 本步验收:制造一次"工具组缺结果"的崩溃夹具,重启后 reducer 补 canonical error,而不是把半开 batch 送回模型。
第 9 步 · Native Tool 目录与 `finalize_response`
目的
把确定性编排做成具名 Tool 子类目录:schema、安全策略、execute() 同文件;并用 Engine 自有的 finalize_response 关闭非科学回合。
实现形式
tools/base.py:Tool/ToolSpec/invoke()(禁止直接execute绕过信封)tools/registry.py:TOOL_TYPES唯一内置实例化点;禁止注册bash/submit_outputagent/finalize.py:封闭 schema(summary/findings/metrics/artifacts/limitations/next_steps/completion_bullets)- 科学库广度不膨胀工具数:仅
science_list_dbs+science_search
技术栈
- JSON Schema 子集校验(自研/内置)
- provider adapters 把
ToolSpec译成各厂 wire
功能定义取舍
| 做 | 不做 |
|---|---|
| 只读工具可按 resource_keys 并行波次 | 可变工具并行(屏障后改串行) |
writes_files=True 在 Web 边界做 Artifact 事务 |
在 Dispatcher 里双重登记内核写文件 |
| finalize 元数据 ToolSpec 仅用于模型可见 | 把 finalize 放进 REGISTRY 可被插件替换 |
优劣分析
- 优:分类清晰;权限元数据与行为不漂移。
- 劣:目录会长;要靠 taxonomy/
side_effect_class管住。
如何与前后模块配合
route_action识别 finalize;WebActionExecutor/ CLI runtime 执行 batch;结果进 Ledger。
flowchart LR
SPEC["ToolSpec 目录"] --> LLM["provider tools 参数"]
LLM --> CALLS["native tool_calls"]
CALLS --> RA["route_action"]
RA -->|batch| INV["Tool.invoke → Dispatcher"]
RA -->|finalize| FIN["finalize.py 校验 → CompletionRecord"]
9.4 控制工具并行波次(实现时别抄错)
只读且 resource_keys 不冲突的前缀可以并行;第一个可变/未知调用是屏障,之后串行;写回历史必须按 provider 原始顺序。并行完成顺序绝不能打乱 canonical group——否则 replay 与 compaction 全坏。
9.5 finalize_response 最小 schema 心智
模型必须给出:
summary(有根据地)completion_bullets(1–4 条已完成动作短语)- 可选 findings/metrics/artifacts/limitations/next_steps
Host 再校验一遍同一封闭 schema。科学 Cell 不要用它收尾,继续用 host.submit_output。
✅ 本步验收:试图
register_tool(bash)失败;solefinalize_response产生 structured completion;夹带其他 tool 时不走 FinalizeAction。
第 10 步 · Skills:代码配方,不是 JSON Schema
目的
把领域知识(对接、单细胞、折叠流程)写成可加载配方:SKILL.md + 可选 kernel.py sidecar。模型先看见 name+summary,需要时再 host.search_skills / load_skill——渐进披露。
实现形式
- 仓库
skills/<name>/SKILL.md openai4s/skills_loader/loader.py- 用户技能仅
<data_dir>/user-skills;bundled 同名冲突时 bundled 只读胜出 - sidecar 使用前 compile-check
- Host 创作流:
draft → personal;Web Customize 为userorigin,不能伪造 trusted origin
技术栈
- Markdown frontmatter + 文件树
- 与 Store 的 capability 状态代际绑定(loader 不缓存已 close 的 repo)
功能定义取舍
| 做 | 不做 |
|---|---|
| Skill = 代码配方 / 操作手册 | Skill = 另一个 JSON tool schema 市场 |
| 渐进披露 | 把 34 份全文塞进系统提示词 |
| sidecar 结构门禁 | 任意用户代码无检查直接 import |
优劣分析
- 优:科学流程可版本化、可评审;引擎保持瘦。
- 劣:配方质量参差会变成产品体验;需要 readiness 检查。
如何与前后模块配合
- Tool:
search_skills/load_skill;Cell 内host.skills.*;远程 compute skill 带provider.py(第 20 步)。
flowchart TD
CAT["catalog: name+summary"] --> M["模型决定是否加载"]
M -->|load| MD["SKILL.md 全文"]
M -->|sidecar| PY["kernel.py compile-check"]
MD --> CELL["Cell 按配方执行"]
PY --> CELL
10.4 写你的第一个 Skill(最小配方)
skills/insulin-quicklook/
SKILL.md
SKILL.md 建议结构:
---
name: insulin-quicklook
summary: 从 UniProt 拉取 INS 摘要并在内核中计算序列长度
---
# Insulin quicklook
1. 用 science_search 查 UniProt: INS human
2. 将序列写入 workspace
3. 开一个 python cell 计算 len(seq) 并作图
4. host.save_artifact(...)
5. host.submit_output(...)
不要把配方写成"神秘 JSON 工具"。配方的读者是模型与人类审阅者。
✅ 本步验收:未 load 时系统提示不含全文;load 后 Cell 可 import sidecar;用户技能无法 shadow 内置目录。
第 11 步 · Gateway:`http.server` + 手写 WebSocket
目的
把引擎产品化成本机工作台:REST 管资源,WS 管回合流式事件。继续零框架——科学家环境少一份依赖冲突。
实现形式
server/gateway.py:组成适配器(大,但是门面)WSHub:订阅root_frame_id、广播、断线续游标SessionRunner:会话状态、任务队列、run_message、_loopserver/webui/:index.html/app.js/style.css无构建步骤,直接从工作树提供静态文件- 默认 bind
127.0.0.1;本地 auth / 安全头(CSP 等)
技术栈
http.server+ 手写 WS handshake/frames(见ws_frames.py)- 线程模型:阻塞 POST 跑回合 + WS 推送
功能定义取舍
| 做 | 不做 |
|---|---|
| stdlib HTTP/WS | FastAPI/uvicorn 核心硬依赖 |
| 静态工作树 WebUI | React 构建链进 MVP 关键路径 |
| 单例 pidfile daemon | 多守护进程抢同一 DB |
优劣分析
- 优:
./start.sh简单;JS 改完刷新即得。 - 劣:gateway 文件巨大,新路由要落到 focused service,避免继续膨胀。
如何与前后模块配合
- 下一章专讲发送路径;Artifact/权限/恢复服务挂同一 runner。
flowchart LR
B["Browser"] -->|REST| G["gateway.py"]
B -->|WS| H["WSHub"]
G --> SR["SessionRunner"]
SR --> ENG["AgentEngine"]
SR --> H
G --> STATIC["webui/*"]
✅ 本步验收:
openai4s serve后 GET/200;WS 升级成功;第二实例因 pidfile 拒绝启动。
第 12 步 · WebUI 发送路径:composer → 气泡
目的
打通用户能感知的完整闭环:输入框 → POST message → 外环多回合 → WS 事件 → 助手气泡 / Activity / Artifact。这是验收产品的主路径,也是本教程最关键的一张时序图预演。
实现形式(对照源码)
- composer:
server/webui/app.js的send(text, opts) - 如无会话:
POST /frames创建,并sub(frameId)订阅 WS - 乐观渲染用户气泡;若
S.running则标记 queued(服务端 FIFO 接纳) - 先
sub(currentId),再 POST——否则首包text_reset/text_chunk会因未订阅被丢 POST /frames/{id}/message(单数;wait:false)→ Gateway 返回 202 +{job_id, execution_id, request_id};turn 在后台线程跑,不阻塞 HTTPSessionRunner.submit_message→ FIFO ticket +MessageJob→run_messageSessionRunner._loop组装AgentEngine+ChatModel+WebActionExecutor+CompactionPolicy+WebEventSink- 引擎回合中:
TextDelta/ tool / cell 事件 →emit→WSHub.broadcast(每条 stamped 单调seq) - 前端把 chunk 拼进助手 bubble;工具变 Activity 卡;写文件变 Artifact
- 完成:
server/completions.py把结构化 output / bullets / artifact delta 投影为最终助手消息,再发终端frame_update;S.running=false,composer 解锁
WS 续传契约(必须写进产品):connectWS() 连 /api/v1/ws;view_session 带 since_seq(本 tab 已应用的最大 seq)与 epoch(daemon 进程身份)。daemon 重启后 epoch 变了 → replay_begin.gap=true → UI 必须 REST 重拉历史,不能假装「你已追上」。缓冲只覆盖当前进行中的 turn,不是全量事件日志。
技术栈
- fetch REST + WebSocket 客户端
- 服务端 threading + Event cancellation
- 手写 WS:
ws_frames.py;resume 靠seq+epoch,不是 Socket.IO
功能定义取舍
| 做 | 不做 |
|---|---|
wait:false + 202 立即返回 |
同步阻塞到整轮 Agent 结束(会卡死浏览器) |
| 发送中再输入 → 排队 | 静默丢弃(旧 bug) |
| 客户端生成 annotation admission id | 只靠服务端 id(断线不可恢复) |
| 计划模式改写 payload、禁工具 | 计划模式仍允许随便写文件 |
/skillname 转硬指令 |
指望模型自己想起 load_skill |
since_seq + epoch 续传 |
把 UI transcript 当唯一真相 |
优劣分析
- 优:用户理解的"聊天"与真实账本解耦;刷新可重拉 messages;202 让 Stop / 排队 UX 可做。
- 劣:订阅时序、admission、队列提示、epoch 间隙都是细节雷区。
如何与前后模块配合
- 完整模块级回放见文末 🎬;权限卡与 Artifact 事件插在同条 WS 流。
sequenceDiagram
autonumber
participant U as 用户
participant UI as app.js composer
participant REST as gateway POST
participant SR as SessionRunner
participant ENG as AgentEngine
participant K as Kernel/Tools
participant WS as WSHub
participant B as 助手气泡
U->>UI: 输入提示词并发送
UI->>UI: 乐观用户气泡;openTurnTicket
UI->>WS: view_session(fid, since_seq, epoch)
UI->>REST: POST /frames/{id}/message wait:false
REST-->>UI: 202 execution_id
REST->>SR: submit_message → FIFO → run_message
SR->>WS: text_reset
SR->>ENG: _loop → Engine.run
loop 每个 turn
ENG->>ENG: model.complete (SSE deltas)
ENG-->>WS: text_chunk / notebook_cell / activity seq++
WS-->>B: 流式渲染
ENG->>K: Tool batch 或 Cell
K-->>ENG: observation
end
ENG-->>SR: submitted / finalize / max_turns
SR-->>WS: completions 投影 + frame_update terminal
WS-->>B: 完成态
B-->>U: 可读回复 + Artifact 入口
12.4 前端必须做对的四件时序事
- admission id 在 POST 前生成并记住(注释/钉选场景);服务端发的 id 在断线后帮不上忙。
sub(frameId)在 POST 前;首包 delta 不可丢。- 用 dispatch 当下的
S.running快照决定是否 openTurnTicket——中间有 await,旧快照会过期。 - 不要把 header 上的 model 覆盖会话钉选 revision(源码注释里写过这个坑)。
- 重连时带
since_seq+epoch;epoch 变化必须 REST 重拉,不可盲信缓冲。
12.5 服务端 run_message 责任清单
- 校验 frame/project 作用域
- 写入用户消息(含 annotation 消费)
- 忙则入队,闲则开线程(
wait:false路径) - 线程内:领 FIFO ticket →
_loop→completions投影完成消息 → 释放 ticket → 拉下一条队列 - 任何异常:写 terminal failure(注意同 job 不要双写)
✅ 本步验收:浏览器手动跑一句;Network 里可见
POST .../message返回 202;WS 有text_reset/text_chunk;刷新后历史仍在;运行中再发送出现排队而非消失;杀 daemon 重连后 epoch 变化触发历史重拉。
第 13 步 · 权限与审批卡
目的
科学 Agent 会写文件、联网、跑 shell。产品必须把风险动作变成可点的审批卡,并且:无浏览器订阅 ≠ 自动放行;无头默认 deny。
实现形式
permissions.py+ Store 中 permission rules/requests- HostDispatcher 在风险工具前
ask - WebUI 渲染审批卡;决议写回 SQLite
- 语义:
- 进程仍在:唤醒同一阻塞调用
- daemon 已重启:记录
permission_resolution(旧操作未执行),要求 Continue/replan once:15 分钟内精确匹配 conversation/tool/target,原子消费OPENAI4S_UNATTENDED_APPROVAL=deny|allow
技术栈
- 持久化请求行 + 条件变量/事件唤醒
- 前端 WS 事件
permission_request
功能定义取舍
| 做 | 不做 |
|---|---|
| 审批存活跨重启 | 把红acted payload 当参数重放执行 |
| 会话/项目/全局 standing rule | MVP 就做复杂 IAM 角色系统 |
| Notebook REPL 默认关 | 默认开放任意内核执行入口 |
优劣分析
- 优:实验室可接受的安全叙事;审计完整。
- 劣:重启后"点了允许但没执行"需要 UI 文案教育,否则用户以为 bug。
如何与前后模块配合
- Tool 类声明 permission target;bash capability 另层绑定 generation。
sequenceDiagram
participant T as Tool/host call
participant D as Dispatcher
participant DB as SQLite
participant UI as 审批卡
T->>D: 风险动作
D->>DB: 写入 permission_request
D-->>UI: WS 推卡
UI->>DB: allow once/session/...
alt daemon 未重启
DB-->>D: 唤醒原调用
D-->>T: 继续执行
else daemon 已重启
DB-->>UI: requires_continue
UI->>T: 用户显式 Continue/replan
end
✅ 本步验收:无头
deny下风险工具失败;有 UI 时卡可点;重启后 once 不复燃旧参数。
第 14 步 · Artifact 版本化与溯源
目的
科学家要的不是"模型说过画了图",而是磁盘上的版本对象:可预览、可回滚、可说明"由哪个 Cell / 哪个环境 / 哪些输入"产生。
实现形式
server/artifacts.py:ArtifactManager(捕获、版本、CAS)- Cell 事务结束:savefig、workspace diff → 新版本
host.save_artifact/ nativeSaveArtifactTool- provenance:绑定 kernel generation 的 runtime/interpreter/env_name/generation_id
- 内核内
kernel/provenance.py:对象级 lineage tags(可OPENAI4S_PROVENANCE_OFF=1) - 检索结果带 provenance envelope(DB、请求、时间、SHA-256)
技术栈
- 内容寻址文件 + SQLite 元数据
- matplotlib 图由 gateway 负责 savefig(不要在 worker 里 autoclose)
功能定义取舍
| 做 | 不做 |
|---|---|
| 版本是 Artifact 的属性时间线 | 每次覆盖同一路径无历史 |
| 环境指纹来自真正跑过的 interpreter | 错贴 daemon 的 package freeze(比缺失更糟) |
| 导入 session 时不重映射外部检索 provenance | 假装别人机器上的哈希是你本地的 |
优劣分析
- 优:可复现叙事;UI 可做"produced by cell N"。
- 劣:大文件与版本爆炸需要配额与清理策略(后期)。
如何与前后模块配合
- finalize/submit 可引用 artifact ids;导出 package 携带版本与 provenance。
flowchart TB
CELL["Cell 写文件 / savefig"] --> CAP["ArtifactManager.capture"]
CAP --> V["artifact_version"]
GEN["kernel generation"] --> V
LIN["lineage_edges"] --> V
V --> UI["Artifact 面板"]
V --> EXP["session package"]
✅ 本步验收:R Cell 产物的 provenance 不得显示成 Python freeze;同路径两次编辑产生两个 version_id。
第 15 步 · 上下文压缩 Compaction
目的
长科研会话会爆上下文。压缩必须保留可复现线索,且不能拆开原生工具原子组或 code/observation 对。
实现形式
agent/compaction.py:estimate_tokens/should_compact/compact/CompactionPolicy- 大输出落盘为 content-addressed archive,消息里留预览 + SHA-256
- 结构化 handoff 字段:Objective / Constraints / Decisions / Done / In Progress / Blocked / Next Move / Key Artifacts / Active Kernel Generation
- 系统提示与站立上下文(skills/memory)每回合重建,不被 compaction 误伤成"对话太长"
技术栈
- 纯函数模块 + 可选 LLM 摘要(
SUMMARY_FORK) - 无 Store 硬依赖(元数据由调用方注入)
功能定义取舍
| 做 | 不做 |
|---|---|
| 分组不可拆 | 简单从头截断 messages |
| 分项预算(text/images/tools/wire) | 单一字符粗算 |
| 归档 JSON-compatible only | 把内核内存对象伪序列化进摘要 |
优劣分析
- 优:长会话仍可续跑;费用可控。
- 劣:摘要质量依赖模型;要测"压缩后仍知道当前 generation"。
如何与前后模块配合
AgentEngine每回合context_policy.prepare;Web 传入 branch/ledger 元数据。
flowchart LR
M["messages"] --> E["ContextEstimate"]
E -->|超阈值| C["compact"]
C --> S["structured handoff"]
C --> A["disk archive sha256"]
S --> M2["准备给模型的短历史"]
✅ 本步验收:构造超大 tool result,压缩后出现 preview+hash,且 tool 组不被拆到非法状态。
第 16 步 · 委派 Delegation
目的
主 Agent 把子任务并行派给子 Agent(文献一组、清洗一组),但必须有预算与深度叶子,否则会分形炸机。
实现形式
agent/delegation.py:树、预算、取消、steer- 上限:
FANOUT_CAP=48,SESSION_CAP=1000,MAX_DEPTH=4(第 4 层不能再委派) host.delegate/collect/stop_child/send_message- 取消精确到子树;停掉的孩子不能再发布晚到输出
- Gateway 在
SessionState上挂共享DelegationBudget
技术栈
ThreadPoolExecutor+contextvars传递树- 子 Agent 复用同一
AgentEngine循环
功能定义取舍
| 做 | 不做 |
|---|---|
| 会话级累计 spawn 预算 | 子完成就退还 spawn(防刷) |
| 深度叶子 | 无限递归委派 |
| 内存 steer 队列在 turn 边界消费 | 只在启动时塞一次 prompt |
优劣分析
- 优:真并行科研拆分。
- 劣:资源与权限传播复杂;子 Agent 的 workspace/policy 要显式。
如何与前后模块配合
- Host
DelegationService;UI composer 有 delegation 选项;Ledger 记子运行。
flowchart TB
R["Root Agent"] -->|delegate| C1["Child ≤ depth 4"]
R --> C2["Child"]
C1 -->|depth<4| G["Grandchild"]
C1 -->|depth=4| L["Leaf 不可再 delegate"]
R -->|collect| OUT["汇总结果"]
✅ 本步验收:深度 4 再 delegate 失败;
stop_child后无迟到submit_output;session spawn 超限报错。
第 17 步 · FIFO 执行协调与会话内核所有权
目的
同一会话里,Agent 回合、用户 REPL、环境切换、恢复写者不能抢内核。需要精确 owner + lease 的 FIFO,而不是"对会话 PID 乱发 SIGINT"。
实现形式
execution/:协调器与 watchdog(CLI 也要用,故不放server/)server/execution_coordinator.py:Web 适配kernel/supervisor.py:Python/R slot 生命周期;从不读协议帧- 中断必须带
execution_id+owner.kind+owner.id+ frozen lease - 环境切换 build-first:新 worker 就绪才切指针
技术栈
- 排队票据 + 条件变量
- generation UUID 防 ABA
功能定义取舍
| 做 | 不做 |
|---|---|
| 取消排队写者不杀当前写者 | 会话级盲目 interrupt |
| supervisor 与 Kernel 单 reader 分离 | 多个线程读同一 stdout |
| Notebook REPL 默认只读轨迹 | 默认开放多行 REPL |
优劣分析
- 优:恢复与并发可测;避免"杀错代进程"。
- 劣:概念多(ticket/lease/generation),文档要写清楚。
如何与前后模块配合
run_message、cell_run、recovery 都来领票;Artifact 捕获挂在 ticket 生命周期内。
flowchart LR
Q["Agent / REPL / lifecycle / recovery"] --> FIFO["ExecutionCoordinator"]
FIFO --> OWN["exact owner + lease"]
OWN --> K["Kernel.execute"]
STOP["Stop"] --> OWN
✅ 本步验收:回合运行中排队第二条消息;Stop 只打断当前 ticket;模拟旧 watchdog 无法杀掉新 generation。
第 18 步 · 科学连接器与证据包
目的
把 UniProt/RCSB/ChEMBL/PubChem/arXiv/OpenAlex 等变成两个工具 + host.science.*,并强制 provenance,使结果成为证据而非"模型记忆"。
实现形式
tools/science.py+ host science service- 固定 HTTPS 端点走网络开关、SSRF/redirect 守卫、egress、审批、untrusted-output 筛查
evidence.py/ session package 导出- workflows/ +
openai4s/benchmark/:真实 Store/kernel 上的科学工作流基准(LLM/网络可注入)
技术栈
- stdlib HTTP 客户端封装(经 Host)
- JSON 规范化版本号
功能定义取舍
| 做 | 不做 |
|---|---|
| 每库一个 connector 归一化 | 为每个库暴露 10 个原生工具 |
| 响应字节哈希 | 只存"模型摘要过的序列"当真相 |
| 基准断言 failure/denied | 只断言"没抛异常就算过" |
优劣分析
- 优:工具表稳定;证据可复查。
- 劣:上游 API 变更要维护归一化;限流要有降级。
如何与前后模块配合
- 首条用户故事的主路径;Artifact
source=provenance;安全层共用。
flowchart LR
Q["science_search"] --> C["connector normalize"]
C --> P["provenance envelope"]
P --> A["save_artifact(source=P)"]
A --> E["evidence / export"]
✅ 本步验收:mock 上游返回后,artifact version 含 sha256 与 timestamp;改字节则哈希变。
第 19 步 · 安全分层(纵深防御)
目的
单一沙箱救不了科研 Agent。必须把互相独立的层叠起来:OS 沙箱、环境 allowlist、预执行分类、dlopen 钩子、生物安全、注入标注、egress、会话密钥、CSP……
实现形式(层表)
| 层 | 默认 | 作用 |
|---|---|---|
| OS kernel sandbox | auto |
Seatbelt/bwrap;enforce 失败则拒绝启动 |
| Child env allowlist | 开 | 不把 daemon 密钥遗传给 Python/R |
| Pre-exec classifier | heuristic |
筛 agent 写的 Cell |
| dlopen audit | 开 | 拒绝对 agent 可写路径 .so |
| Biosecurity | 开 | ALLOW/ESCALATE/BLOCK |
| Injection scan | 开 | 网页/PDF/MCP 当数据不当指令 |
| Egress allowlist | off(可开) |
应用层出站策略 |
| Secret store | auto |
keychain/env,失败封闭 |
| Data-dir perms | 开 | 0700/0600 |
| Browser headers | 开 | CSP 等 |
| Permission broker | 开 | 与第 13 步一体 |
| Bash capability token | 开 | generation 绑定一次性 |
技术栈
- 平台分支(darwin/linux);自我测试探针
- 静态/启发式/可选 LLM 分类
功能定义取舍
| 做 | 不做 |
|---|---|
auto 可见降级 |
静默"以为沙箱在" |
| bind loopback | 默认 0.0.0.0 暴露局域网 |
| Windows 报告 unsupported | 假装 ACL 等价 |
优劣分析
- 优:单层失效仍有其他层;合规叙事完整。
- 劣:调参多;CI 必须跑 Linux 分支。
如何与前后模块配合
- 贯穿 Kernel spawn、Host 网络工具、打包后的冒烟。
flowchart TB
CELL["Agent Cell"] --> CL["pre-exec classifier"]
CL --> SB["OS sandbox"]
SB --> ENV["allowlisted env"]
ENV --> RUN["worker"]
RUN --> RPC["host RPC"]
RPC --> EG["egress + permission"]
RPC --> INJ["injection annotate"]
19.4 本地暴露的正确方式
需要同事访问时:
- ✅ SSH tunnel 到
127.0.0.1:8760 - ✅ 明确的本地 auth / token 门闩(若你加了)
- ❌ 在不可信网络
0.0.0.0裸奔 - ❌ 把 daemon 当多租户网关
docs/security.md 开篇就写了:先读再暴露。把这句话抄进你的 README。
19.5 生物安全与注入是产品功能,不是合规贴牌
生信用户会碰到双刃内容。轨迹筛查默认开;网页工具结果必须标注"数据不是指令"。关掉它们要显式 env,并在 doctor 里显示姿势。
✅ 本步验收:关沙箱 self-test 失败时
auto报告 degraded;enforce不起 worker;web_fetch默认拒 link-local。
第 20 步 · BYOC 远程计算(可选后期)
目的
本地笔记本跑不动 AlphaFold/大模型推理时,把 job 投到用户自己的 GPU(SSH 或 NIM),同时保持密钥与沙箱边界。
实现形式
openai4s/compute/:宿主侧 manager/registryopenai4s_compute_provider/:远程侧 stdlib worker SDK- Skills:
skills/remote-compute-<id>/(provider.json+provider.py) host.compute.*/host.fold(严格不伪造)- 密钥两段擦除:入口
scrub_secret_env→ 载入 provider 后再按 prefix 擦 - 远程 helper 再套一层 confinement(家目录不可读等);网络不隔离(本来就要调 API)
技术栈
- SSH / docker CLI 传输(非通用 pluggable Transport 层)
- 暂不实现 Modal/SLURM 一等公民(可用 SSH 上手写 sbatch)
功能定义取舍
| 做 | 不做 |
|---|---|
| Job 生命周期 stage→run→harvest | 把推理 endpoint 与 job 混成一个概念 |
provider 发现自 remote-compute-* |
把任意 skill 当 compute provider |
| fold 无结果就失败 | 编造结构交叉骗用户 |
优劣分析
- 优:BYOC 契合实验室资产;核心仍瘦。
- 劣:Prototype 体验锋利;文档必须写清信任边界。
如何与前后模块配合
- 建立在稳定的 Host/审批/Artifact 之后;打包阶段可先不捆绑 GPU 栈。
sequenceDiagram
participant A as Agent
participant H as Host compute
participant R as Remote provider SDK
participant G as GPU node
A->>H: compute.create/submit_job
H->>H: approval + scrub
H->>R: stage + confined helper
R->>G: run
G-->>R: out.tar.gz
R-->>H: harvest
H-->>A: artifact paths
✅ 本步验收:无凭证时 fold 失败且不造假;helper 在 confinement self-test 失败时 exit 且不读凭据。
第 21 步 · 打包分发:dmg / pip / WSL(后期)
目的
让不会 git clone 的科学家也能用:Apple Silicon dmg、Linux tarball、Windows zip→WSL2、以及 pip install openai4s。
实现形式
pyproject.toml发布 wheel;skills*/envs*/workflows*必须进 package datascripts/释出管线、DMG 构建、verify_release_artifacts.py- macOS app:内嵌 Python + 默认科学栈;ad-hoc 签名,需 Gatekeeper 指引
- Windows:启动器装入 WSL2,跑同一 Linux 构建;原生 Windows 拒绝起内核
openai4s doctor/diagnostics/verify-package
技术栈
- uv / pip;平台脚本;可选 micromamba 内核环境
功能定义取舍
| 做 | 不做 |
|---|---|
| 控制面轻量 venv + 可选内核环境 | 把完整 conda 塞进 pip 依赖 |
| 明确平台支持矩阵 | "应该能在 Windows 原生跑"的模糊承诺 |
| 发布校验脚本 | 只靠人工点开 dmg |
优劣分析
- 优:分发面覆盖实验室主流机器。
- 劣:三平台释出成本高;公证/签名是持续负担。
如何与前后模块配合
- 前置所有运行时不变量;
workflows漏打包会导致 benchmark 假绿——CI 必须挡住。
flowchart TB
SRC["源码 + skills/envs/workflows"] --> WHEEL["pip wheel"]
SRC --> DMG["macOS arm64 dmg"]
SRC --> TGZ["linux x86_64 tar.gz"]
SRC --> ZIP["windows zip → WSL2"]
WHEEL --> V["verify_release_artifacts"]
DMG --> V
TGZ --> V
✅ 本步验收:
uv build后 wheel 内能发现 skills 与 workflows;在干净机器pip install后openai4s serve能起。
动手实验室:三条最小命令验证你的骨架
在接 WebUI 之前,先用 CLI 证明双平面:
# 1) 工具/对话完成路径(不起科学内核也可)
uv run openai4s run "用一句话解释什么是胰岛素,并 finalize。" -v
# 2) Code-as-Action 完成路径
uv run openai4s run "Compute the mean of [4,8,15,16,23,42] and submit it." -v
# 3) 起守护进程(需已配模型或 UI 内配置)
./start.sh
# 浏览器打开 http://127.0.0.1:8760/
离线测试(默认套件 mock LLM):
uv run pytest tests/test_agent.py -q
uv run pytest tests/test_kernel.py -q
对照源码仓库时,记住:pytest 绿 ≠ 发布绿。还有 response schema/contract、harness、directory README、secret scan、browser smoke。
🎬 完整回放:用户输入一段提示词之后如何被打包上传接收反复运作直到返回聊天窗口
场景提示词(用户在工作台输入):
「帮我查人胰岛素 INS,从 UniProt 拉序列摘要,写一段 Python 计算序列长度并画一个最简单的长度标注图,保存产物,最后用中文总结。」
下面按真实模块顺序列出每一次触达。编号与时序图 autonumber 一致。
模块触达清单(请对照着看)
| # | 模块 / 文件 | 做什么 |
|---|---|---|
| 1 | server/webui/app.js send() |
读 composer、乐观用户气泡、计划/技能改写 |
| 2 | POST /frames(若无会话) |
SessionRunner 建 frame + workspace |
| 3 | WS sub(frameId) |
前端保证订阅先于 POST |
| 4 | POST /frames/{id}/message |
gateway.do_POST 路由 |
| 5 | SessionRunner.run_message |
准入、排队、开 turn 线程 |
| 6 | execution FIFO |
领取 execution ticket / lease |
| 7 | prompts.py + skills catalog |
组装系统提示(渐进披露) |
| 8 | store messages/frames |
持久化用户消息 |
| 9 | SessionRunner._loop |
装配 Engine 端口 |
| 10 | agent/compaction.py |
prepare 上下文 |
| 11 | llm/client.py + transport |
流式请求方舟/其他 |
| 12 | agent/engine.py |
turn 循环 |
| 13 | agent/actions.py route_action |
选择 Tool / Cell / Finalize |
| 14 | tools/* + HostDispatcher |
例如 science_search |
| 15 | permissions |
若需审批则推卡并阻塞 |
| 16 | agent/ledger.py |
写入 action group/events |
| 17 | kernel/* |
惰性启动 Python,执行 Cell |
| 18 | sdk/host.py |
Cell 内 save_artifact / submit_output |
| 19 | server/artifacts.py |
版本捕获 + provenance |
| 20 | WebEventSink + WSHub |
text_chunk / steps / artifacts |
| 21 | app.js 渲染 |
气泡、Activity、Artifact 面板 |
| 22 | finalize 或 submit_output |
终端完成投影 |
| 23 | Store 助手消息 | 刷新后仍在 |
sequenceDiagram
autonumber
participant U as 用户
participant UI as app.js
participant GW as gateway.py
participant SR as SessionRunner
participant FIFO as ExecutionCoordinator
participant ENG as AgentEngine
participant LLM as llm.chat urllib
participant RA as route_action
participant HD as HostDispatcher
participant PERM as permissions+SQLite
participant K as Kernel worker
participant SDK as sdk/host
participant ART as ArtifactManager
participant LED as ActionLedger
participant WS as WSHub
participant STORE as Store SQLite
U->>UI: 输入提示词,回车
UI->>UI: 乐观渲染用户气泡;清空 composer
alt 无 currentId
UI->>GW: POST /frames
GW->>SR: 创建 SessionState + workspace
GW->>STORE: 持久化 frame
GW-->>UI: {id}
UI->>WS: sub(id)
end
UI->>WS: sub(currentId) 再发消息前强制订阅
UI->>GW: POST /frames/{id}/message wait:false
GW->>STORE: append user message
GW->>SR: run_message(...)
SR->>FIFO: 申请 ticket(若忙则排队)
FIFO-->>SR: 获得 owner+lease
SR->>ENG: _loop 装配 ports 后 Engine.run
Note over ENG,LLM: Turn 1 · 控制平面检索
ENG->>ENG: CompactionPolicy.prepare
ENG->>LLM: complete(messages, tools, on_delta)
LLM-->>WS: text deltas(经 WebEventSink)
WS-->>UI: text_chunk → 助手气泡流式出现
LLM-->>ENG: ModelReply(tool_calls=[science_search...])
ENG->>RA: route_action → NativeToolBatch
ENG->>LED: 打开 tool action group
ENG->>HD: Tool.invoke → science_search
alt 需要审批
HD->>PERM: permission_request
PERM-->>UI: 审批卡
U->>UI: Allow once
UI->>PERM: resolve
PERM-->>HD: 继续
end
HD-->>ENG: 归一化结果 + provenance
ENG->>LED: append tool results(原子组)
ENG->>STORE: usage / frames tokens
Note over ENG: observation 写回 messages,turn+=1
Note over ENG,K: Turn 2 · 科学平面 Cell
ENG->>LLM: complete(...)
LLM-->>UI: 可选短说明文本
LLM-->>ENG: ModelReply(content 含 ```python)
ENG->>RA: route_action → CodeCell(python)
ENG->>LED: cell attempt group
ENG->>K: 若无 worker则 lazy spawn + sandbox
K->>K: 执行分析代码,matplotlib 作图
K->>SDK: host.save_artifact(...)
SDK->>HD: host_call save_artifact
HD->>ART: 登记版本
ART-->>WS: artifact 事件
WS-->>UI: Artifact 面板出现
K->>SDK: host.submit_output(output, bullets)
SDK->>HD: completion
HD-->>ENG: CompletionPort 置位
ENG->>LED: terminal submitted
ENG-->>SR: EngineResult(stop_reason=submitted)
SR->>STORE: 投影最终助手消息(中文总结+bullets+artifact delta)
SR-->>WS: terminal / done 类事件
WS-->>UI: 气泡定格;Stop 隐藏;composer 解锁
UI-->>U: 可读结论 + 可点开的图与版本
Note over FIFO: 释放 ticket;若有排队消息则下一轮 run_message
这一条链路上,你写的代码在哪?
| 环节 | 谁的代码 |
|---|---|
| 聊天 UI、订阅时序、排队提示 | 你的(WebUI) |
| REST/WS、会话、投影 | 你的(Gateway/SessionRunner) |
| 外环路由、完成契约、账本 | 你的(AgentEngine 系) |
| 多供应商 HTTP | 你的(llm urllib) |
| 权限、审计、科学 API、Artifact | 你的(Host/Store) |
| 持久内核协议与沙箱 | 你的(kernel/security) |
| 具体怎么查 INS、怎么画图、怎么解释生物学 | 模型的 + Skills 配方 |
| 方舟/Claude 权重怎么推理 | 供应商的 |
推理可以租,科研运行时必须是自己的。
若模型选择"工具-only 然后 finalize"
路径在 Turn 2 变为:FinalizeAction → agent/finalize.py 校验 → 无内核启动 → 仍经 Ledger 与消息投影回气泡。这正是双平面的意义:不是事事进内核。
若用户在运行中又发了一句
send() 发现 S.running:气泡标 queued → 同一 POST messages 被 FIFO 接纳 → 当前 ticket 结束后自动开下一 turn。旧版"直接 return 丢消息"是产品级事故,不能回去。
附录 A · 验收清单
| 步 | 断言 |
|---|---|
| 0 | 90 秒讲清:双平面、stdlib、非 IDE、非 SaaS、¥9.9、平台约束 |
| 1 | 新 Tool/Host/SQL/UI 落点表可用;指出一种错误落点 |
| 2 | 占位符 Key 视为未配置;provider 专用 Key 可共存 |
| 3 | fake transport 下 ark/anthropic 归一化 tool_calls 同形 |
| 4 | finalize / tools-over-code / 单 Cell / 未闭合围栏 单测绿 |
| 5 | Tool 与 host.* 同方法共享审批与 audit |
| 6 | submit_output 能结束 run;分析内核无 delegate 属性 |
| 7 | 两 Cell 共享变量;纯 finalize 不起 worker |
| 8 | 半开 tool 组崩溃后 reducer 补 canonical error |
| 9 | 不能注册 bash/submit_output;sole finalize 才是 FinalizeAction |
| 10 | 未 load 无全文;用户技能不 shadow bundled |
| 11 | serve 单例;/ 与 WS 可用 |
| 12 | 先 sub 后 POST;运行中再发送会排队;刷新历史仍在 |
| 13 | 无头 deny;重启后 once 不重放参数 |
| 14 | R 产物 provenance 非 Python freeze;同路径两版本 |
| 15 | 大结果变 preview+sha;tool 组不拆坏 |
| 16 | depth=4 禁委派;stop 无迟到输出 |
| 17 | Stop 带精确 ticket;旧 lease 杀不了新 generation |
| 18 | science 结果带哈希;benchmark failure 用例会因成功而失败 |
| 19 | sandbox auto 可见降级;fetch 拒私网 |
| 20 | fold 不造假;confinement 失败不读凭据 |
| 21 | wheel 含 skills/workflows;干净环境可 serve |
附录 B · 最容易翻的车
① 把完成信号当成"最后一条散文"。
症状:UI 以为结束了,Ledger 无 terminal;或模型空回复。
修法:只有 finalize_response / host.submit_output / 显式取消与 max_turns。
② 同回合并行 Tool 与 Cell。
症状:权限与科学状态交错,无法审计。
修法:route_action 控制面绝对优先,每步单通道。
③ 在 worker 里 autoclose matplotlib。
症状:图"算过了"但 Artifact 空。
修法:gateway 在 Cell 后 savefig/close。
④ Artifact 环境指纹取自 daemon。
症状:R 分析显示 Python 包列表——错误的 provenance 比没有更糟。
修法:绑定 kernel generation 的 interpreter freeze。
⑤ POST 早于 WS subscribe。
症状:首回合流式字全丢,结束后突然冒整段(或什么都没有)。
修法:send() 在 POST 前强制 sub(currentId)。
⑥ 运行中发送直接 return。
症状:用户以为发出去了,composer 已清空。
修法:FIFO 排队 + queued 样式。
⑦ 双份 Host 执行路径(Tool 走 A,host.* 走 B)。
症状:一边要审批一边偷偷跑。
修法:唯一 HostDispatcher 信封。
⑧ 把 finalize_response 注册进 Tool 市场。
症状:插件替换完成契约,Ledger 终止态失控。
修法:Engine 自有 FinalizeAction。
⑨ 内核继承 daemon 环境变量。
症状:子进程打印里出现 API Key;供应链噩梦。
修法:allowlist 重建环境。
⑩ sandbox auto 失败却当已隔离。
症状:演示安全、实际裸奔。
修法:UI/doctor 显式 degraded;真正要强制用 enforce。
⑪ Windows 原生硬跑内核。
症状:fd 重定向与沙箱全面炸。
修法:拒绝启动 + WSL2 包装同一 Linux 构建。
⑫ wheel 忘记打进 workflows* / skills*。
症状:openai4s benchmark 0 workflows 0 failures exit 0——假绿。
修法:package data + verify 脚本。
⑬ 审批 payload 重放执行。
症状:重启后"允许"了一次过期危险参数。
修法:重启只记 resolution,要求 Continue/replan。
⑭ compaction 拆开 tool 原子组。
症状:下次请求 provider 拒收半开 tool batch。
修法:声明+结果一起保留或一起归档。
⑮ mid-cell RPC 多飞行。
症状:帧交错死锁。
修法:worker _HOST_CALL_LOCK + manager 单 reader。
B.13 帧上限按 ASCII 拍脑袋
症状:中文/emoji 多时 Notebook 丢 traceback。
根因:字符上限 × 错误的每字符字节假设。
修法:按最坏 UTF-8/JSON 转义推导(见 worker.py 注释);加回归测试。
B.14 worker 里 autoclose matplotlib
症状:「图明明 show 了但 Artifact 没有」。
根因:gateway 还没 savefig,worker 先关了 figure。
修法:捕图责任在 gateway;worker 禁止擅自 close。
B.15 用 daemon 的 pip freeze 当 R 产物环境
症状:溯源面板看起来很完整,论文复现对不上。
根因:错误的 provenance 比没有更糟。
修法:绑定写出文件的 kernel generation;否则显式记缺失。
附录 C · 源码对照索引
基线:参考项目/OpenAI4S @ 85e9fa0。
| 教程步骤 | 源码锚点 |
|---|---|
| 总论双环 | docs/architecture.md · CLAUDE.md The dual loop |
| 第 1 步 布局 | openai4s/ 包树 · docs/backend-extension-guide.md |
| 第 2 步 配置 | openai4s/config.py |
| 第 3 步 LLM | openai4s/llm/client.py · llm/transport.py · llm/providers/* |
| 第 4 步 引擎 | openai4s/agent/engine.py · ports.py · actions.py route_action |
| 第 5 步 信封 | openai4s/host_dispatch.py · openai4s/host/* |
| 第 6 步 SDK | openai4s/sdk/host.py · sdk/bash.py · sdk/compute.py |
| 第 7 步 内核 | openai4s/kernel/manager.py · worker.py · lazy.py · r_kernel.py |
| 第 7 步 沙箱 | openai4s/security/sandbox.py · kernel/environment.py |
| 第 8 步 账本 | openai4s/agent/ledger.py · store.py · storage/* |
| 第 9 步 Tools | openai4s/tools/registry.py TOOL_TYPES · agent/finalize.py |
| 第 10 步 Skills | skills/*/SKILL.md · openai4s/skills_loader/loader.py |
| 第 11 步 Gateway | openai4s/server/gateway.py · ws_frames.py · server/webui/* |
| 第 12 步 发送路径 | webui/app.js send · SessionRunner.run_message · _loop |
| 第 13 步 权限 | openai4s/permissions.py · docs/security.md 审批段 |
| 第 14 步 Artifact | openai4s/server/artifacts.py · kernel/provenance.py |
| 第 15 步 压缩 | openai4s/agent/compaction.py |
| 第 16 步 委派 | openai4s/agent/delegation.py · host/delegation.py |
| 第 17 步 FIFO | openai4s/execution/* · kernel/supervisor.py |
| 第 18 步 科学 | openai4s/tools/science.py · docs/science-connectors.md · workflows/ |
| 第 19 步 安全 | docs/security.md · openai4s/security/* · egress.py |
| 第 20 步 BYOC | openai4s/compute/* · openai4s_compute_provider/ · docs/compute.md |
| 第 21 步 打包 | pyproject.toml · scripts/* · docs/platforms.md · docs/startup-guide.md |
| CLI 组合 | openai4s/agent/loop.py · runtime.py · openai4s/cli/* |
| Web 组合 | openai4s/server/agent_run.py · gateway.py SessionRunner |
结语:这条路线适合谁
适合你,如果:
- 你的用户是科学家,工作对象是数据、结构、图表与证据包
- 你愿意维护一个真内核 + 真账本,而不是只包一层 ChatGPT UI
- 你需要在脏本地环境里靠 stdlib 起 daemon,并用 ¥9.9 级 API 跑通
- 你认同:控制平面可审计,科学平面可计算,二者不要捏成一种假工具
不适合你,如果:
- 你要做的是仓库级 coding IDE Agent(去看本系列 Claude Code / Cline / Codex 路线)
- 你要做多租户云 SaaS 自动隔离(本架构的信任根是本机单用户)
- 你只想接一个 SDK 两周交差、接受不了 SQLite 账本与沙箱细节
最后一句:OpenAI4S 让人记住的不是"又一个 Agent 循环",而是它把科研里真正贵的东西——持久计算、证据、权限、环境指纹——做成了产品不变量;模型可以换,方舟可以 ¥9.9,这些不变量不能换。
平面有两层,核心零依赖;租来的是推理,留下的是科学运行时。
本教程基于 参考项目/OpenAI4S commit 85e9fa0 写成。口吻与结构对齐《从零构建AI设计Agent-OpenDesign系-开发全流程教程》。配套阅读:OpenAI4S-源码分析。