跳转至

19 · 实战二:组合、预算与持久化的所有权

18章已经使资料与工具可替换。这一章回答产品问题:谁启动这些插件,何时停止花费请求,关闭后留下什么。代码仍固定rc.2;核心入口是 src/composition.ts,运行检查点不需要付费模型。

19.1 先画出谁拥有谁

createNotesHarness(options)生成profile overlay,再构造官方 DeepSeekHarness。SDK启动同版本 dsh --profile sdk-minimal,由CLI加载完整应用树。宿主只负责配置、发任务、解释结果与关闭。

sequenceDiagram participant App as 业务宿主 participant Factory as composition participant SDK as 官方SDK participant Runtime as dsh profile App->>Factory: endpoint / notes / budget / home Factory->>Factory: 写notes.patch.json Factory->>SDK: sdk-minimal + patch App->>SDK: run(question) SDK->>Runtime: initialize,session/prompt Runtime-->>SDK: 日志事件和root idle SDK-->>App: result App->>Factory: close Factory->>SDK: shutdown并确认退出 Factory->>Factory: 临时home删除;显式home保留

sdk-minimal bundle是一棵独立完整树,包含真实loop、Session、模型adapter、存储与SDK server。它默认还有persistent Shell和danger-full-access政策,因此本例显式关闭两个Shell工具row。底层subprocess/terminal服务可以仍存在;实际模型工具表只有search_notes/read_note。

19.2 用实际patch理解组合,不复制占位配置

阶段2保存的 artifacts/checkpoint-2/notes.patch.json 可以直接打开。关键变化是:禁用persistent-bash/persistent-pwsh;替换llm-deepseek完整config为本次HTTP地址、API-key环境引用与stream-idle timeout;insert provider、consumer、policy三个row。

不要把 baseURL写成 baseUrl。row config是整份替换,所以maxSteps与previewChars都明确填写。provider只允许notes或notesPath恰好一项;若两者都有或都没有,工厂立即拒绝,不生成两个同名服务。

04章给出可复制的dump命令。先在dump看部署树,再在requests.json看实际模型schema,两份证据分别说明“组合了什么”和“这次请求看见了什么”。一个存在但disabled的工具row不能算模型权限。

19.3 第三个请求值得多少钱:先定义预算单位

node dist/checkpoints.js 4

阶段4回到阶段2的正常读取基线(denyReadIds为空),执行search与read,但 maxSteps:2 阻止第三次模型请求。应看到 requests:2、reason:"blocked"、taskStatus:"budget-exhausted",验收中的evidenceRead为true,citationValid为false。资料已取到,却没有最终回答,这正是预算耗尽的可观察结果。

consumer里的预算使用 WeakMap<Agent,{turn,count}>。新turn重置为0;pre-step达到上限返回reject;否则await next,仅下游enter时加一,原样返回完整decision。保留原decision很重要:别的插件可能改过messages或设置startsRequestSeries。重建一个自己猜的enter对象,会破坏其他插件的语义。

const decision = await next()
if (decision.kind === 'enter') budget.count++
return decision

这里的“已准入”精确指pre-step链返回enter;后续prepareCall仍可能失败而没有HTTP。重试则在同step内,通常不会重新经过pre-step。故它限制进入的step数量,并不严格等于网络请求计数。本fixture无重试,所以阶段4恰好看到两次HTTP。

限额 本例配置 实际限制对象
maxSteps 默认8,阶段4为2 当前live Agent、当前turn的pre-step enter次数
maxTokens 2048 一次模型请求输出上限
streamIdleTimeoutMs 5000 流没有活动的时间
requestTimeoutMs 15000 SDK一次RPC等待,非run总时间

若产品要求“任务最多60秒或固定费用”,还需宿主deadline与使用量账本。关闭独占runtime可终止它拥有的活动,但影响其中所有会话;不能把共享runtime的关闭包装成单会话cancel。费用需要实际provider usage/价格,fixture的固定usage只检验数据路径,不能拿来估算真实成本。

19.4 配置属于宿主,资料属于provider

cwd是Session工作目录,processCwd是dsh子进程起始目录;本例都指向同一显式workspace。它们不构成文件读取围栏。模型只拿opaque note id,真正文件路径由宿主config提供。

默认home来自mkdtemp,用于实验隔离;显式传home则由调用者拥有并保留。持久home会保留自己的profile、patch和Session,也可能已有home级patch;这时应审核实际dump。此工厂按单一宿主使用同一home设计,不支持多个宿主同时覆盖同名notes.patch.json。多知识域产品宜分别拥有home/进程,再设计资料授权。

TS SDK的env替换子进程环境,因此工厂只传PATH、显式key/baseURL和关闭telemetry的变量。Python SDK是合并环境,Python demo先由Node用白名单启动Python,再由它启动dsh。环境隔离和文件权限分别设计,不能由一个空env推断所有主机资源不可访问。

19.5 正常关闭后,留下一份可冷读的事实

node dist/checkpoints.js 5

阶段5回到阶段2的正常读取与8步预算基线,使用 artifacts/checkpoint-5/home。run返回后先保存SDK的events与HTTP请求;随后await owned.close确认runtime收尾;最后 read-saved-session.ts加载同版官方JSONL provider,open(sessionId,'read')并read,写成 cold-read.json。它没有Agent、模型或SDK server,是只读数据检查器,不是第二套应用launcher。

代码逐一按seq比较SDK收到的事件与冷读逻辑事件,要求相同。一次本地运行冷读25条事件,但数量会随插件组合变化;真正验收对象是事件内容与对应关系。物理文件含framing,不能永远用逐行JSON.parse冒充逻辑Session reader。

运行两次阶段5会在同一home产生两个新Session;不覆盖旧Session。新runtime没有按旧ID自动resume。当前SDK对未知ID走create,冷继续需要AgentRegistry.resume、ACP或Web控制器相应路径,见14–16章。保存历史、冷读历史、继续执行历史是三项不同的产品能力。

19.6 将运行结局映射到业务结局

本例summary同时打印 reason与 taskStatus。判定顺序是:call-2的成功tool/result中确有完整证据;最终文本引用该ID;文本符合这份固定问答的预期。阶段0是runtime-only,阶段3是insufficient-evidence,阶段4是budget-exhausted,阶段1/2/5才是answered-with-evidence。

这是一道固定fixture的可执行验收题,不是通用语言语义判分器。真实模型产品应加入多问题、多来源、不存在资料、错误引用与拒绝场景,检查事实支持与成本。completed、Promise resolve和有文字都只提供部分证据。

19.7 增加写工具之前要补什么

只读快照没有远端副作用。若下一版支持“把摘要发布到知识库”,先设计操作ID、目标版本、外部提交回执和查询状态工具,再把它接进相同tool pipeline。pre gate可以阻止开始,post block可以改变反馈,却不能撤销已经发布的文档。Session记下事实;业务系统要拥有幂等提交与补偿。

读者的完成练习:让notesPath换成自己的小型JSON,保持工具consumer不动;为一个ID加deny;将maxSteps调为2/3比较;保存home并确认原回答证据仍可冷读。只有这条路径通过后,再挑10–17章需要的外部能力。