unknown 进入边界,运行时校验后才成为领域事实;每次 Run 只有一个状态所有者;取消沿整条调用链传播;副作用依靠幂等账本而不是 TypeScript 类型保证。从边界组织代码而非按 SDK 示例堆文件
按 SDK 名称建 openai/、langgraph/ 目录,会让业务规则跟着依赖升级移动。更稳定的切分是:Domain 拥有任务、状态和业务不变量;Application 拥有 Run 用例;Adapters 对接 Model、Tool、Store 和 Transport;Infrastructure 组装配置、Telemetry 与部署。
前后端共享的应是生成自同一 Schema 的 DTO/Event,而不是服务端 SDK 类型。Composition Root 在进程启动时解析配置和秘密,创建长生命周期连接与 Runner;领域层不读取 process.env、全局单例或 HTTP Request。
静态类型不能替代运行时校验
TypeScript 类型在运行时被擦除。JSON、模型输出、Tool 结果、环境变量和数据库旧记录都应先视为 unknown,在边界处由 JSON Schema、Zod 等运行时 Schema 解析。尽量从同一 Schema 推导类型,避免“类型、校验器、文档”三份事实漂移。
判别联合适合表达互斥事件和状态,配合 never 做穷尽检查;它能在新增事件时让编译器暴露漏处理分支。不要用一个带大量可选字段的接口表达所有状态,那会产生“completed 却没有 output”之类非法组合。
type RunEvent =
| { type: 'run.started'; runId: string }
| { type: 'tool.requested'; callId: string; tool: string; input: unknown }
| { type: 'tool.completed'; callId: string; output: unknown }
| { type: 'run.waiting'; reason: 'approval' | 'input' }
| { type: 'run.failed'; code: RunErrorCode }
| { type: 'run.completed'; output: FinalOutput }
function reduce(state: RunState, event: RunEvent): RunState {
switch (event.type) {
case 'run.started': return start(state, event)
case 'tool.requested': return requestTool(state, event)
case 'tool.completed': return completeTool(state, event)
case 'run.waiting': return wait(state, event)
case 'run.failed': return fail(state, event)
case 'run.completed': return complete(state, event)
default: {
const unreachable: never = event
return unreachable
}
}
}
异步流需要取消、背压和清理
AsyncIterable 适合领域事件流,WHATWG ReadableStream 适合 Web Transport;适配层负责转换。消费者变慢时不能无限把 Token/Event 放进内存:设置有界队列、合并非关键增量、暂停上游或在策略允许时断开。背压是资源控制,不是 UI 优化。
AbortSignal 从入站请求贯穿 Model、Tool、Store 和子任务。取消表示“调用方不再等待”,不保证远端副作用已经停止;对已提交动作要查询执行账本。并发 Tool 使用显式上限,Promise.allSettled 只收集结果,不自动定义部分失败政策。
async function* runWithCancellation(
input: RunInput,
deps: Dependencies,
signal: AbortSignal,
): AsyncGenerator<RunEvent> {
signal.throwIfAborted()
const run = await deps.store.create(input, { signal })
try {
yield { type: 'run.started', runId: run.id }
for await (const event of deps.model.stream(run, { signal })) {
signal.throwIfAborted()
yield await deps.reducer.accept(run.id, event, { signal })
}
} finally {
await deps.telemetry.flushRun(run.id)
}
}
这段是边界示例,省略了 Tool Loop 与持久化细节;finally 只做必须清理的资源,不应把业务完成写在其中。流关闭、失败事件和取消要有不同终态。
依赖注入保持业务逻辑可测试
Runner 依赖小而稳定的端口接口,而不是导入全局 SDK Client。测试可替换 Fake Model、内存 Store、虚拟 Clock 和故障 Tool;生产 Composition Root 再注入真实实现。接口不应照抄供应商响应,而应表达业务真正需要的语义。
RunContext 只携带显式身份、租户、Deadline、策略版本、Trace 和请求级服务,不把整个 Express/Fastify Request 传到 Tool。这样可以阻止 Tool 随意读取 Header/Cookie,也能序列化需要持久恢复的最小上下文。秘密只通过受控凭据提供者按调用获取,不进入模型 Context 或持久状态。
用稳定错误类型跨层传递恢复语义
错误模型至少区分 Validation、Policy、RetryableDependency、Conflict、Cancelled 和 UnknownOutcome。HTTP 状态、Provider 错误码或 SDK Exception 在 Adapter 层映射为稳定内部语义;原始 cause 留给受控日志,不直接暴露用户。
| 类型 | 可否重试 | 系统动作 |
|---|---|---|
| Validation | 修正输入后 | 返回字段问题,不消耗模型重试 |
| Policy | 不可自动重试 | 拒绝或转审批 |
| RetryableDependency | 预算内 | 退避、抖动、尊重 Deadline |
| Conflict | 读取新状态后 | 重新规划或合并 |
| Cancelled | 由新请求决定 | 停止派生工作,核对副作用 |
| UnknownOutcome | 禁止盲重试 | 查询幂等账本或人工处置 |
禁止浮动 Promise 和未处理 rejection;异步 fire-and-forget 必须交给明确的 Queue/Task Owner。用户错误响应使用稳定码和可行动信息,详细 Stack、Prompt、Token 和敏感 Tool 参数只进入受限诊断域。
测试分层覆盖概率与确定性边界
状态机、Schema、策略和 Context Builder:单元测试聚焦确定性核心:非法状态转移必须拒绝,Schema 边界要覆盖缺失、额外和极值字段,权限策略包含正反例,Context Builder 不泄露无关数据。这里不接真实模型或网络。
Model/Tool/Store Adapter:契约测试用 Provider/服务的录制样本与受控测试环境验证字段、流事件、错误映射、超时和幂等。Adapter 升级先跑兼容集,不能把 SDK 编译成功当成协议兼容。
录制模型事件或 Fake Model 驱动完整 Run:场景测试让 Fake Model 按脚本产生 Tool Call、修正、审批和终态,验证 Application、Reducer、Store 与输出协议协同。真实模型 Eval 另行运行,避免 CI 被概率和外部容量污染。
超时、取消、重复、乱序和部分失败:故障测试注入超时、取消、重复、乱序、部分结果、旧 Schema 和进程重启,重点检查系统是否进入唯一终态、避免重复副作用并留下恢复证据。
模型 Eval 与代码 Test 分开:Eval 衡量模型在分布上的任务表现,Test 证明确定性不变量。CI 中固定 Fake/录制流保证可重复;定期在线或离线 Eval 检查真实模型漂移。不要把真实 Provider 的一次成功当成单元测试。
Node 运行时的生产细节
复用 HTTP/TLS 连接和模型 Client,限制每租户与全局并发,监控事件队列、堆内存与 Event Loop Delay。长字符串、Tool 大结果和未消费流是常见内存源;大 Artifact 外置,Context 只保留必要摘要与引用。
收到 SIGTERM 后先停止接收新 Run,标记或等待可安全完成的在途任务,持久化可恢复状态,Flush Trace,再在 Deadline 内退出。Serverless/Edge 生命周期短,不能依赖进程内 Session、未等待 Promise 或进程退出时才 Flush;需要外部 Store/Queue 和请求内提交。
Source Map、结构化日志和 Trace 都带应用版本、Prompt/Schema/策略版本和 Run ID。发布升级前验证旧状态可读取;若不能,采用版本化 Reducer、迁移任务或让旧 Worker 排空,不能让新代码猜旧对象。
主动提取:为什么 TypeScript 类型通过仍不能信任 Tool 结果?
故障判断:客户端断开后是否应立即取消所有 Tool?
资料与事实边界
以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。
- TypeScript Handbook: Narrowing — 判别联合、never 与穷尽检查
- TypeScript Handbook: Basic Types — unknown 等边界类型的语言事实
- Node.js Web Streams API — ReadableStream、Async Iteration 与取消语义
- Node.js Stream API — 背压、销毁与 AbortSignal 集成
- Node.js Async Context — 请求/Trace 上下文传播的运行时基础