工程不变量外部输入先以 unknown 进入边界,运行时校验后才成为领域事实;每次 Run 只有一个状态所有者;取消沿整条调用链传播;副作用依靠幂等账本而不是 TypeScript 类型保证。

从边界组织代码而非按 SDK 示例堆文件

按 SDK 名称建 openai/、langgraph/ 目录,会让业务规则跟着依赖升级移动。更稳定的切分是:Domain 拥有任务、状态和业务不变量;Application 拥有 Run 用例;Adapters 对接 Model、Tool、Store 和 Transport;Infrastructure 组装配置、Telemetry 与部署。

责任分层:Application 编排端口,Transport 与 Adapter 都依赖内部契约,Domain 不反向依赖 SDK
TransportHTTP/SSE/WebSocket/A2A;把线协议映射为用例输入。
Application启动、恢复、取消 Run,协调领域与端口。
Adapters实现 Model、Tool、Store、Queue、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 的一次成功当成单元测试。

可观察验收给定同一 Golden Scenario,重复事件不产生重复副作用;中途取消进入 Cancelled 或 UnknownOutcome;恢复后从合法 Checkpoint 继续;所有终态都有 Trace、业务流水号和版本元数据。

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 结果?
类型只约束编译时声明,远端 JSON、旧数据库记录和模型输出在运行时不会自动校验。必须从 unknown 经 Schema 解析,才能成为领域事实。
故障判断:客户端断开后是否应立即取消所有 Tool?
取决于任务契约。可取消的只读工作可传播 Abort;已经提交的副作用必须查询幂等账本,Durable 任务可能继续运行并让客户端以后重连。断开不是统一业务终态。

资料与事实边界

以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。

  1. TypeScript Handbook: Narrowing — 判别联合、never 与穷尽检查
  2. TypeScript Handbook: Basic Types — unknown 等边界类型的语言事实
  3. Node.js Web Streams API — ReadableStream、Async Iteration 与取消语义
  4. Node.js Stream API — 背压、销毁与 AbortSignal 集成
  5. Node.js Async Context — 请求/Trace 上下文传播的运行时基础