DeepSeek Harness 工具参数 schema DSL:从否决 Schemastery 到统一 JSON 值词汇的设计决策
本文围绕 DeepSeek Harness 仓库中一份已归档的架构决策笔记(2026-06-11-custom-schema-dsl.zh.md)展开,讲解"工具参数必须以标准 JSON Schema 到达模型,同时工具作者在 execute(args) 中拿到类型化参数且无需任何类型断言"这一双重目标是如何落地的。读完本篇,你可以理解 ParameterSchemaSpec / InferArgs / parameterSchemaSpecToJsonSchema() / defineTool() 四件套的职责分工,知道为什么项目否决了已 vendor 的 Schemastery,并能定位到 packages/core/tools/src/schema.ts 中的编译器实现与类型级回归测试。
问题的双重约束
架构笔记在"问题"一节把需求浓缩为一句话:
工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在
execute(args)中获得类型化的参数而无需类型断言。
这两个约束之间存在天然张力:
- 模型侧要求的是"线上协议格式"(wire format)——一个能被 LLM 正确理解的 JSON Schema 对象,即
type: 'object'、properties、独立的required: string[]数组; - 作者侧希望的是一种贴着属性的书写习惯——在每个属性旁边写
required: true,而不是在对象外面再维护一份required键名列表,否则两处声明很容易失同步。
值得注意的是,仓库此时已经 vendor 了 Schemastery(用于插件 Config 的校验/转换),但笔记明确指出:工具作者 API 需要的是逐属性的 required: true 布尔值,而非 JSON Schema 的独立 required 数组。这个"看起来只差一个字段"的差别,最终演变成整个自定义 DSL 的立项理由。
决策:自定义类型化工具 schema DSL
该决策的现行形态已被后续的统一 JSON 值 schema DSL取代,但原笔记中的四个核心构件全部保留,构成 dsh-tools 的类型化作者面:
| 构件 | 职责 | 源码位置 |
|---|---|---|
ParameterSchemaSpec |
逐属性参数声明,保留 required: true 约定 |
schema.ts#L96-L106 |
InferArgs<S> |
类型推导:必需键 → 非可选属性,其余 → 可选属性 | schema.ts#L119-L131 |
parameterSchemaSpecToJsonSchema() |
把隐式开放对象根编译为原始 JSON Schema | schema.ts#L449-L458 |
defineTool() |
把类型推导、编译与运行时校验串联起来 | schema.ts#L545-L617 |
作者面:逐属性 required: true
ParameterSchemaSpec 在类型上就是"一个可带 required: true 标注的属性映射",映射本身是隐式开放的对象根:
// 摘自 packages/core/tools/src/schema.ts
export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
/** 工具参数 schema。该映射本身是隐式开放的对象根;
* 必需性仍然是逐属性的 `required: true` 标注。 */
export type ParameterSchemaSpec = {
[key: string]: ParameterPropertySpec
[key: symbol]: never // 运行时编译拒绝 symbol 键
}
[key: symbol]: never 这一条不是装饰:类型层直接封死 symbol 键,运行时编译器也会拒绝非自有可枚举字符串键的输入(见统一 JSON 值 schema DSL 笔记中"自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明")。
一个完整的第一方工具长这样(摘自 adding-a-tool.zh.md):
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // 模型看到的内容
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // 默认可选
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 类型自动推导为 { path: string; limit?: number }
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
execute 签名中的 args 类型由 InferArgs<S> 给出:required: true 的键推导为非可选属性,其余键推导为可选属性。作者全程不写 as,也不手写接口。
InferArgs 的类型级映射
推导的核心是两个条件类型(schema.ts#L119-L131):
/** 属性映射中标记了 `required: true` 的键。 */
type RequiredKeys<S> = {
[K in StringKeyOf<S>]: S[K] extends { required: true } ? K : never
}[StringKeyOf<S>]
/** 把隐式属性映射推导为"必需键 + 可选键"的对象类型。 */
type InferProperties<S, Depth extends unknown[]> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K], Depth> }
& { [K in Exclude<StringKeyOf<S>, RequiredKeys<S>>]?: InferProperty<S[K], Depth> }
>
值推导走 InferValueAt(schema.ts#L153-L172):标量优先匹配 const / enum 字面量约束,再退回宽类型;容器节点每深入一层消耗一次 Depth,精确推导以 16 层容器为界,超过后回退到 JsonValue。这个有界设计是刻意的——它避免了 TypeScript 类型实例化栈深度反过来限制作者能声明的嵌套深度,而运行时的 schema 强制执行在任意深度仍然精确。
编译:parameterSchemaSpecToJsonSchema()
编译产物是一个对象根的原始 JSON Schema,逐属性的 required: true 在这里被收拢为标准 JSON Schema 的 required 数组:
// 摘自 packages/core/tools/src/schema.ts
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
const compiled = compilePropertyMap(spec, 'parameters')
const schema: ParameterJsonSchema = {
type: 'object',
properties: compiled.properties,
...(compiled.required === undefined ? {} : { required: compiled.required }),
}
assertSupportedJsonSchema(schema)
return schema
}
注意"隐式开放"体现在:生成的根对象不写 additionalProperties 限制,保留 JSON Schema 默认的开放语义;而显式声明的 object 节点则必须写明 additionalProperties: true | false,漏写会在编译期直接抛错(schema.ts#L365-L372)——这是笔记中"对象开放性规则"的落地,目的是让嵌套或输出对象"永远不获得一个意外的 JSON Schema 默认值"。
defineTool():推导、编译、校验的串联点
defineTool()(schema.ts#L545-L617)是唯一需要作者接触的入口,它在定义阶段完成三件事:
- 编译参数 schema:
parameterSchemaSpecToJsonSchema(options.parameters)立即执行,格式错误的声明(不支持的键、required非true、缺失additionalProperties、循环引用)在定义或注册时快速失败,而不是拖到模型调用时才暴露; - 编译输出 schema:
valueSchemaSpecToJsonSchema(options.output.schema)处理工具输出侧; - 注入校验包裹:返回的
ToolDefinition.execute先把原始args交给validate()(内部即validateJsonSchemaValue(parameters, args, '')),有任何违规就抛出带逐条violations的ToolArgsError(schema.ts#L461-L470),校验通过后再以InferArgs<S>的类型断言移交用户回调——类型体操的代价被封死在核心包内部,作者侧零断言。这正是笔记"后果"一节所说的"符合 AGENTS.md 的类型安全策略"。
回放场景(presentCall / presentResult / isConcurrencySafe)对历史日志参数采用软校验:校验失败不抛错,而是回退到通用渲染或返回 false,避免旧 schema 的日志参数让 UI 回放崩溃(schema.ts#L594-L615)。
为什么否决 Schemastery
笔记的"曾考虑的替代方案"一节只列了一项,但结论干脆:
Schemastery(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema 生成,因此会增加间接层却无法干净地产出协议格式(wire format)。
拆开看这个否决理由:Schemastery 解决的是"给定一个 StandardSchema 形态的 validator,做校验与数据转换";而工具作者路径需要的是"从一份类型化声明生成模型可读的 JSON Schema"。方向相反,中间塞一个适配器只会多一层间接,产出物(wire format)反而不干净。
这个判断在后来的统一 JSON 值 schema DSL 笔记中被再次确认并扩展:"使用 Schemastery 处理工具参数:不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。" 该统一笔记还顺带否决了"采用完整 JSON Schema 或 Ajv"——理由是 harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构,接受更大的语言子集会让"强制执行能力"与事实不符。
仓库中 Schemastery 的位置可查:其声明出现在 apps/cli/package.json、THIRD_PARTY_NOTICES.md,使用场景集中在插件 Config 的校验(参见 config-catalog.md),与工具参数路径互不干扰。
源码纵深:为什么编译器是显式工作栈
阅读 runSchemaCompiler 会看到一个不寻常的实现选择:整个 schema 编译不用递归下降,而是把每个节点表示为 CompileTask 压入栈中迭代执行(schema.ts#L222-L240)。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染全部采用这种显式工作栈策略,因此"运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制"。
同一处还处理了几个防御性细节,值得对照阅读:
- 防原型污染:向
properties写入编译节点时用Object.defineProperty而非直接赋值,避免__proto__键名带来的赋值语义问题(schema.ts#L242-L263); - 词表校验:
assertAuthorKeys()拒绝节点上出现的任何声明外键(例如在string节点上写minLength),错误信息精确到路径,如parameters.path.minLength is not supported by the value schema DSL; - 循环检测:
seen集合 +leave任务实现 DFS 进出配对,重复对象直接报is circular; required语义收紧:属性上的required字段若存在,值必须严格为true,required: false这类写法会被拒绝(schema.ts#L292-L295),从源头消灭"假可选"声明。
oneOf 分支的语义也在这里固化:要求恰好匹配一个分支,既不是"首个匹配即通过"(分支顺序会改变语义),也不容忍重叠分支同时命中——这保证了模型输出的歧义能被校验器抓住。
统一化演进与类型级回归测试
原笔记在"后果"中留了一个伏笔:InferArgs 映射在类型层面有回归测试,源于早期一个可选性 bug(即某个本应必需的键一度被推导成了可选属性)。
这个回归测试如今在 ts-types.spec.ts 中,直接对 parameterSchemaSpecToJsonSchema 的产物做渲染断言,把"必需键不得为可选"锁死在类型投影里:
const schema = parameterSchemaSpecToJsonSchema({
path: { type: 'string', required: true, description: 'Absolute file path' },
limit: { type: 'number' },
})
// 期望渲染结果:path: string;(无 ?)
// limit?: number;(可选)
测试还覆盖 jsonSchemaToTs 对全部统一构造的映射(ts-types.spec.ts#L9-L38):enum 展开为字面量联合、const 展开为单字面量、开放对象渲染为 & Record<string, JsonValue>、封闭对象渲染为 Record<string, never> 等。配合 schema.spec.ts、tools.spec.ts 的运行时用例,"类型推导、编译、校验三者一致"这一不变量同时被编译期和运行期两层测试守住。
演进脉络上,本笔记(2026-06-11,已归档)到统一 JSON 值 schema DSL(2026-07-20)的关键增量是:作者面从"只有参数"扩展到"参数与值共享一套词汇"——ValueSchemaSpec 成为可描述任意 JSON 根类型的节点集合(含 { type: 'json' } 这一作者侧语法糖,编译为仅注解、不约束的原始节点,schema.ts#L74-L77),output.schema 因此可以使用对象、数组、标量或 null 任意根类型,而不再被迫重复实现第二条推导/编译/校验路径。
逃生舱:原始 JSON Schema 直注册
笔记特别保留了一条外部通道:
原始 JSON Schema 的
ToolDefinition仍是ToolRegistry.register()接受的输入,供 MCP 和其他外部工具使用。
对应实现是 ToolRegistry.register 直接接收 ToolDefinition(parameters 字段为原始 JSON Schema 对象)。这意味着:
- 走
defineTool()的第一方工具享受类型推导、快速失败与统一校验; - 来自 MCP 等外部来源、自带 JSON Schema 的工具直接注册,自行负责输入校验;
- 统一代码生成遇到不受支持的原始 schema 结构时"视为未知类型",不会假装自己能强制执行——这与前面否决完整 JSON Schema/Ajv 的理由一脉相承。
小结
这条决策线展示了 DeepSeek Harness 处理"模型协议格式"与"作者类型体验"冲突的工程手法:不在两侧各建一套系统,而是设计一个小的、词表受限的作者 DSL(逐属性 required: true、显式对象开放性、类型正确的 enum/const、恰好匹配一个分支的 oneOf),让类型推导(InferArgs/InferValue)、JSON Schema 编译(parameterSchemaSpecToJsonSchema)和运行时校验(validateArgs/ToolArgsError)从同一份声明派生,类型体操的成本全部收敛在 dsh-tools 核心包 内部,并以类型级回归测试锁定关键不变量。Schemastery 则回到它擅长的位置——插件 Config 的 StandardSchema 校验——继续服役。
延伸阅读:
- 原决策笔记(中文/英文):2026-06-11-custom-schema-dsl.zh.md、2026-06-11-custom-schema-dsl.md
- 现行统一设计:2026-07-20-unified-json-value-schema-dsl.zh.md
- 子系统文档:tools.md
- 编写工具实战手册:adding-a-tool.md
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00