跳转至

14. 把 Harness 接到自己的程序:TypeScript / Python SDK

本章研究快照是官方主分支 da00f7f5358f2949383b35c14f548bc20187d80c;可安装实验固定为 0.2.0-rc.2,对应 639ed015397290b3745d163aafe02ffee4aa3f84。本章涉及的 TS/Python SDK 核心源码在两个快照间没有变更。接口仍处于发布候选阶段,应用应锁版本和锁文件。本章示例中真正调用模型的部分需要自己的凭据;本课程的离线验证使用替身进程,不消费模型额度。

14.1 SDK 包含什么,以及你的程序负责什么

DeepSeekHarness 是管理子进程的客户端库。它启动同版本的 dsh --profile sdk,经子进程的 stdin/stdout 发送 JSON-RPC;模型适配器、Agent、工具、会话、权限和存储仍在 dsh 进程中运行。Python 也是同一种结构,不在 Python 中另写一套 Agent 循环。公开导出包括高层 DeepSeekHarness、HarnessSession 和低层 HarnessClient。TS 公共入口

flowchart LR A[你的 TS / Python 服务] --> B[SDK 客户端] B -->|启动并拥有| C[dsh --profile sdk] B -->|stdin JSON-RPC| C C -->|stdout 响应与通知| B C --> D[Cordis 插件树] D --> E[Agent / 工具 / LLM] D --> F[会话与附件持久化] C -->|stderr 诊断| A

因此,自己的 Agent 产品可以先写业务入口、排队、权限配置、结果验收与界面,再复用 Harness 的执行引擎。必须由宿主程序承担租户隔离、请求总预算、外部副作用幂等、结果是否满足业务要求等工作。一个 run() 返回值没有替你完成这些产品判断。

两个 cwd 要分清。TS 的 processCwd 是子进程启动目录,cwd 是初始化时记录到 SDK 创建的 Session header 的工作目录。Python 对应 runtime_cwd 与 cwd。它们影响配置发现与工作空间,设置一个并不会自动构成文件系统安全边界。TS 启动选项 Python 配置到客户端的映射

14.2 同版本 runtime 与 profile

TS 默认解析 SDK 自己依赖的 @deepseek-ai/dsh,检查 SDK/runtime 版本相同,再使用其 CLI。显式 dshBin 指的是 CLI 模块路径,正常启动仍使用当前 Node;不是任意 shell 命令。源码目录兼容启动只有在 CLI 源码、兼容 patch 和 tsconfig 都齐备时才成立,并通过 tsx/esm 导入。不要据此推断所有受支持 Node 版本都能原生执行 .ts。版本与启动路径解析

# 在自己的应用目录;Node 使用项目支持的 22.19+ 或 24+ 分支。
npm install --save-exact @deepseek-ai/[email protected]
npm ls @deepseek-ai/dsh-sdk-client @deepseek-ai/dsh

使用 npm 锁文件保存最终解析出的依赖树。编写自己的插件时还应固定它所使用的 Harness 包;不要拿主分支代码中的新增包名去猜 rc.2 已发布包的存在。

默认 profile 是 sdk。sdk-minimal 刻意不继承完整 base:它给需要自己搭建能力树的应用一个较小起点。SDK 服务器握手有一项特定 fallback:找不到 deepseek-official 路由时,会挂载官方 API-key 适配器 LlmDeepSeek;找不到其他 provider 则直接失败。这个例外不会自动补齐凭据、附件、工具或任意缺失服务,应用仍须明确提供自己的能力树。初始化 fallback 每个 patches 项按顺序作为 --patch 传给命名 profile;相对路径在 spawn 前解析。要增加私有工具,先编写正常的 Cordis 插件及 patch,再让 SDK 启动这个配置。不要把测试中的通用进程注入器当成公开插件配置 API。启动参数构造 高层测试构造器与运行器

14.3 一个保守的 TypeScript 入口

保存为自己的 ESM 应用中的 run.mjs。把工作目录和 Harness home 设为应用专用路径,凭据由进程环境或专用 home 的凭据层提供。这里继承环境只是一个明确选择;生产服务应按自己的秘密管理规则选择允许继承的变量。

import { resolve } from 'node:path';
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client';

const harness = new DeepSeekHarness({
  profile: 'sdk',
  cwd: resolve('./workspace'),
  processCwd: resolve('.'),
  dshHome: resolve('./.harness-home'),
  provider: 'deepseek-official',
  model: 'deepseek-v4-flash',
  env: { ...process.env },
  initializeTimeoutMs: 10_000,
  requestTimeoutMs: 15_000,
  maxTokens: 4096,
});

try {
  const session = harness.session();
  const first = await session.run('阅读工作目录,列出实现目标需要的步骤。', {
    onNotification(notification) {
      // 这里是同步观察器;只记录必要元数据,不输出凭据或整段私有对话。
      console.error(notification.method);
    },
  });
  console.log(first.finalResponse);
  const second = await session.run('继续:解释第一步的理由。');
  console.log(second.finalResponse);
} finally {
  await harness.close();
}

先创建 workspace 目录,再运行此例。例中的默认 provider/model 是源码的当前默认,不代表其他模型都支持同一 reasoningEffort 值。握手会调用所选路由的 resolveCallConfig 做适配器配置验证;provider 不认识、参数不合法或模型配置不成立会在握手或后续运行阶段失败。初始化校验

这里 harness.session() 只生成一个对象和 ID,不发送请求;首次 run() 才启动/初始化 runtime,并在服务器第一次收到该 ID 的 prompt 时创建 Agent。重复使用同一个 session 对象会保留同一 runtime 内的上下文。harness.run(text) 不传 sessionId 时每次生成新 ID,容易被误用为连续对话。高层 API 生命周期

环境变量差异非常实际

TS 的 env 是完整替换:传 { DEEPSEEK_API_KEY: key } 会让子进程失去父环境中其他变量。传 undefined 才在 spawn 时读取父环境。Python 则先复制 os.environ,再用 env 覆盖,空字典不会清除父环境。想在 Python 中建立严格的环境白名单,需要在启动宿主的进程层处理,不能把 env={} 当成白名单。TS 环境契约 Python 启动实现

14.4 Python:同一 runtime,不同宿主 API

Python 发布节奏与 npm 不同步:本课程查询时 PyPI 的 deepseek-harness-sdk 和 deepseek-harness-runtime-bin 最新版本均为 0.1.5rc1,npm 实验则是 0.2.0-rc.2。直接 pip install 最新版不能被当成运行 rc.2 源码。Python SDK 发布索引、runtime 发布索引。Python 源码的 pyproject.toml 保留构建占位版本 0.0.0.dev0,发布打包会配置实际版本,不能照占位数安装。课程第 20 章使用注明 tag/commit/MIT 来源的 rc.2 Python SDK 源码副本,加 pydantic,并通过公共 dsh_bin 指向本实验 npm rc.2 的可执行 CLI;这条路径仅在 dsh_bin 缺省时才 lazy import runtime-bin,不需要安装不匹配的 bundled wheel。显式路径在 Python 中是 Popen 的 argv[0],应使用可执行的 .bin/dsh 或 wrapper,而非 TS 所用的不可执行模块路径。详见 版本对照。Python 包声明

以下例子针对仓库中 Python API。安装对应发布的 SDK/runtime 或按官方源码开发方式配置后使用。Python SDK 强制要求显式 dsh_home 或非空 DSH_HOME,不会隐式使用 ~/.dsh,这个条件比 TS 的默认行为严格。Python runtime 解析

from pathlib import Path
from deepseek_harness import DeepSeekHarness

workspace = Path("workspace").resolve()
workspace.mkdir(parents=True, exist_ok=True)
home = Path(".harness-home").resolve()

with DeepSeekHarness(
    cwd=str(workspace),
    runtime_cwd=str(Path.cwd()),
    dsh_home=str(home),
    profile="sdk",
    provider="deepseek-official",
    model="deepseek-v4-flash",
    max_tokens=4096,
    initialize_timeout_seconds=30.0,
    request_timeout_seconds=15.0,
) as harness:
    session = harness.start_session()
    result = session.run("说明工作目录中项目的入口。")
    print(result.final_response)
    print(result.finish_reason)

Python 高层调用是同步的;放进 Web 服务时不能直接阻塞事件循环。可以用受控工作线程/任务进程,并确保异常路径关闭 runtime。start_session() 会先启动初始化,区别于 TS 只创建惰性 handle。Python RunResult 提供 finish_reason,TS 的结果没有这个字段。Python 取最后一个根会话 turn/end 的 reason.kind;业务仍须结合事件和验收规则判断成功。Python 高层运行实现

14.5 线协议:只有三种请求

当前 SDK 协议明确声明 initialize、session/prompt、shutdown 三个请求。不要根据 Web API 或 ACP 的能力向 SDK 发虚构的 session/cancel、session/resume、session/messages。服务器的分派表只有这三项。协议类型 服务器分派与建会话

请求 输入 返回意味着什么
initialize cwd、provider、model,选填 reasoningEffort/maxTokens runtime 接受进程级设置;身份字段是协议身份
session/prompt sessionId、contentBlocks { messageId },消息已提交到 Agent inbox;不是模型最终答案
shutdown 无参数 服务器的自有 Agent/监听器等开始完成关闭流程;客户端还要确认进程退出

serverInfo.version 当前硬编码为 0.0.1,不是 npm 包 0.2.0-rc.2。软件版本核对应读包元数据/锁文件,而不是仅比较握手字符串。stdout 必须是协议流,普通日志写 stderr;给 stdout 加一条调试输出会破坏协议载体。服务器身份 stdio 插件生命周期

四种通知:session.event 是会话日志事件;session.status 是整个 Agent 的 running/idle;subagent.started 建立父子关系;subagent.finished 是本 runtime 中进程内子 Agent 的结果,远端子 Agent 的结束不由这里报告。低层客户端可订阅 session tree,但父子关系是从通知中发现的,不是客户端持有完整全局历史。通知结构 通知树订阅

sequenceDiagram participant App as 宿主应用 participant SDK as 高层 SDK participant Server as SDK 服务器 participant Agent as 根 Agent App->>SDK: session.run(input) SDK->>SDK: 先订阅 session tree SDK->>Server: session/prompt(id, blocks) Server->>Agent: followup(user message) Agent-->>SDK: inbox/spliced,含该 messageId Server-->>SDK: 响应 messageId Note over SDK,Server: 响应和通知可能交错;通知已排队 Agent-->>SDK: turn/start、assistant/message、turn/end Note over Agent,SDK: 可能还有下一轮或异步工作 Agent-->>SDK: session.status = idle SDK-->>App: 根 events、树 notifications、最后助手文本

14.6 run() 的真正完成条件

高层运行器先订阅,再发 prompt,避免“服务器在返回请求响应前就完成了任务”的竞态。收到 messageId 后,它丢弃这次消息的 inbox 收据之前的通知;从匹配 agent/inbox/spliced 开始收集,到根会话第一次进入 idle 为止。TS 收集循环 Python 收集循环

这里还有耐久边界:messageId 是逻辑 inbox 提交收据,session.event 是逻辑日志事件通知,root idle 是逻辑活动结束;三者都不是 fsync acknowledgment。服务器 prompt 路径调用 followup 后直接返回,没有先 await sessions.flush。默认 JSONL 后端可以仍在短批处理窗口中;本课程实际离线测试也观察到 idle 时目录尚未 materialize,而确认关闭后才出现完整 V4 日志。要承诺耐久提交,必须明确调用可验证的持久化屏障并处理失败;第 20 章在关闭确认后检查磁盘,不能只用 run() 返回代替磁盘证据。prompt 的提交点

这有五个后果:

  1. 第一个 turn/end 不一定结束整个活动区间;不能自己见到它就兑现应用的完成 Promise。
  2. 子 Agent idle 不是根 Agent idle。
  3. events 只有根 Session 日志事件,notifications 才有根和已发现的后代通知。
  4. finalResponse 是区间最后一条根助手消息中所有 text block 的拼接;可能为空,也不会自动汇总工具结果或子 Agent 的回答。
  5. idle 只表示运行器此刻没有活动工作,失败、取消或耗尽限制也可能走到 idle。应用应读终止事件、检查交付物和业务验收。

SDK 通知观察器是同步函数,运行器不会 await 你返回的异步 Promise。需要异步数据库写入时,应由应用维护有界队列并 await 自己的 drain;观察器抛出异常会让当前高层收集失败,不能让日志消费者随意抛错。内部订阅队列没有为无限通知提供产品级背压,长任务要控制积累的数据规模。TS 收集调用点 订阅队列

14.7 超时、取消、关闭与恢复

requestTimeoutMs 限制的是某个 JSON-RPC 请求等待响应的时间。session/prompt 很快返回 enqueue receipt 后,活动仍可能持续任意长时间,因此它不是 run() 的总墙钟预算。超时时,客户端放弃该请求的 pending waiter;服务器上的工作没有因此被自动取消。请求超时实现

如果产品需要 60 秒总预算,要另外计时并处理所有权:当前 SDK 没有单 Session 的线协议取消,只能停止观察并明确决定是否关闭自己拥有的 runtime。关闭整个 runtime 会影响其中其他会话,不能把多个互不相关任务放进一个进程后再假装此操作只取消一项。最好按任务或信任域拥有进程,并在 finally 中释放。关闭实现

TS 关闭先尝试有界 shutdown,再关闭 stdin 等待 EOF 清理,随后按需要发送 SIGTERM/SIGKILL,最后确认退出。默认协议关闭 1 秒、EOF grace 6 秒、终止确认窗口 3 秒;不是“调用 kill 后立即完成”。初次初始化失败会尝试清理,只有能确认旧进程退出才能安全重试;无法确认时会抛组合错误,避免偷偷启动第二个未知并存进程。TS close() 是终态,想再启动应构造新实例。Python 有自己的 close/等待步骤,不应套用 TS 的 6 秒 grace 数值。TS 生命周期与重试 进程释放 Python 关闭

恢复也要分层:

场景 当前行为
同一个 SDK runtime,重复 sessionId 复用服务器 map 中的 Agent,继续上下文
新 runtime,传入过去的 sessionId SDK 走 agents.create,不等于打开旧日志恢复;持久化 ID 冲突可能报错
Web UI 打开冷会话历史 只读日志/投影,尚未激活 Agent
Web 控制器继续冷会话或 ACP resume 走专门的恢复、cwd 和身份检查路径

不要写一个“保存 sessionId 后任意重启自动恢复”的产品承诺,再用 SDK create 路径去实现它。持久化确实存在,与此客户端当前暴露的控制面并不相同。SDK 建会话 Web 恢复入口

14.8 内容块与图片

字符串变成一个 text block;数组直接作为内容块传入。SDK 接受 { type: 'image', data: canonicalBase64, mimeType } 的内联图片,服务器先通过附件服务校验并入库,再把稳定图片引用放到消息中。没有附件服务会失败;mimeType 只接受 PNG/JPEG/WebP/GIF 声明且不代替实际媒体验证。图片线类型 附件转换

import { readFile } from 'node:fs/promises';
const bytes = await readFile('./diagram.png');
const result = await session.run([
  { type: 'text', text: '解释这幅图的执行流程。' },
  { type: 'image', data: bytes.toString('base64'), mimeType: 'image/png' },
]);

这段接在前例 session 已存在的 try 块中使用;选择的模型需要支持图片。附件入库成功不代表请求已成功完成,也不代表任意路径的文件都可以被客户端引用。Web 控制面另有附件可达性验证,见下一章。

14.9 不调用模型也能检验 SDK

最有价值的离线测试是替身 runtime 真的读写 stdin/stdout,让请求响应和通知交错,而不是 mock run() 直接返回结果。官方 SDK 测试替身在 packages/sdk/client/tests/fake-runtime.ts;包内 createProcessDeepSeekHarness() 接受通用进程配置,公开入口却没有导出它,因此这属于源码测试 seam,不能对 npm 用户承诺是公共 API。包内替身 seam 替身 runtime

从源码测试可以验证这些性质:握手、同版本定位、prompt receipt 与早到通知、正确 session tree、多个 turn 才到 idle、终止原因解析、stderr 错误尾部、坏握手后退出、EOF 与强制终止。Python 也通过私有 _launch_args 给测试传替身进程,不需要真实模型路由。私有参数仅用于测试,应用上线仍用命名 profile。Python 私有测试构造参数

要验证自己的 Agent 产品,把这些协议测试与业务验收分开:替身证明客户端状态机和生命周期;专门的受控 Agent/plugin 测试证明工具权限与预算;经明确配置的模型集成测试才证明 provider 可用。离线成功不能被写成“DeepSeek 云 API、真实文件权限和 Electron 都已通过”。课程实验章给出这次实际执行记录。

14.10 选择 SDK 的检查清单

准备接入时,先做一项具体决策:你的应用是否只需要“启动、发任务、等 idle、读事件、关闭”。如果是,SDK 能保持宿主业务与执行器的边界清晰。需要标准化取消、冷会话恢复或 MCP 配置时,可研究 ACP;需要人机交互与持续观察时,可研究 Web BFF。三种控制面在 Session/Agent 底座上复用能力,但不能互相借用不存在的 API。

可以继续学习 18. 自己的插件、19. 自己的 Agent 和 20. 实际 SDK 离线实验。这些章节提供真正的 rc.2 profile/patch、自定义服务与工具、HTTP 模型替身和 TS/Python 客户端验证,而不把包内测试私有 seam 当成 npm 公共接口。

上线前至少明确:runtime 的独占范围、工作目录/home 的隔离、环境继承规则、profile/patch 版本、总时间和 token 预算、允许的工具、失败后能否重复副作用、结果如何验收、进程何时释放。这些都是自己的 Agent 的设计输入。