一、前言:ArkTS 不是 TypeScript

如果你从 TypeScript 转到 ArkTS,第一周大概率会写出这样的代码:

// 从智谱或 DeepSeek 拿到一段响应
const text = '{"id":"chatcmpl-1","choices":[{"delta":{"content":"你好"}}]}'
const data: any = JSON.parse(text)
const content = data.choices[0].delta.content  // ← 期望拿到 "你好"
console.info(content)

在 TypeScript 里,这段代码能跑(虽然不严谨)。但在 ArkTS 严格模式下,编译器会报三个错

  1. arkts-no-anyconst data: any 禁止使用 any 类型。

  2. arkts-no-indexed-signaturesdata.choices 这种动态属性访问需要索引签名,而 ArkTS 禁止 [key: string]: T 形式的索引签名。

  3. arkts-no-untyped-obj-literalsJSON.parse 返回的对象是未类型化的,不能直接用 .xxx 访问。

这三条约束不是可以绕过的警告,是硬性编译错误。你会陷入一个困境:大模型的所有交互(请求、响应、流式 chunk、工具参数)都是动态 JSON,但 ArkTS 不让你用动态的方式处理它们。怎么办?

最直觉的解法是"绕过":用 @ts-ignore、用 ESObject、用 as object as SomeType 强制转换。但这些都是技术债——它们把"类型不安全"从编译期推迟到了运行期,原本编译器能在你写代码时抓住的错误(字段拼错了、类型搞错了),现在变成了线上 crash。

ArkAgent 选择了另一条路:正面设计一套类型安全的 JSON 体系,从根上消除对 any 的依赖。这套体系覆盖了从 JSON 文本解析、到动态数据建模、到领域模型编解码、到 Provider 扩展参数守卫的完整链路。本文就拆解这套体系。


二、ArkTS 类型系统的五条约束

先完整认识对手。ArkTS 严格模式通过一组 arkts-no-* 规则,结构性禁止了 TypeScript 里常见的动态特性。和 JSON 处理直接相关的有五条:

2.1 约束清单

约束规则

禁止的行为

对 JSON 处理的影响

arkts-no-any

使用 any 类型

不能用 any 接收 JSON.parse 的结果

arkts-no-indexed-signatures

[key: string]: T 索引签名

不能定义递归的 JsonObject 接口

arkts-no-untyped-obj-literals

未类型化的对象字面量

不能用 { onNext: fn } 实现接口

arkts-limited-throw

throw 任意类型

错误必须是 Error 的子类

JSON.parse 的限制

返回动态对象无法用 .xxx 访问

必须自己解析或用类型安全包装

下面逐一展开。

2.2 arkts-no-indexed-signatures:递归类型别名的死刑

这是最致命的一条。TypeScript 里处理 JSON 最优雅的写法是递归类型别名:

// ❌ ArkTS 编译器直接拒绝
export type JsonScalar = string | number | boolean | null
export type JsonValue = JsonScalar | JsonObject | JsonValue[]
export interface JsonObject {
  [key: string]: JsonValue   // ← 这一行触发 arkts-no-indexed-signatures
}

ArkTS 为什么禁止字符串索引签名?因为索引签名破坏了类型安全的根基——它允许你用任意字符串作为属性名访问,编译器无法在编译期验证属性是否存在、类型是否正确。ArkTS 选择"宁可麻烦,不可不安全"。

ArkAgent 在阶段 1 的首日就真实撞上了这条墙(阶段报告踩坑记录 #2):

现象:字符串索引签名触发 arkts-no-indexed-signatures 根因interface JsonObject { [key: string]: JsonValue } 不属于 ArkTS 支持子集 方案:JsonValue class + JsonKind + Map 封装

有趣的是,ArkAgent 在阶段 0 的 JSON spike 里就已经预判了这一点,并准备好了降级方案:

"若阶段 1 最小编译验证拒绝递归 alias,立即使用 JsonValue class + JsonKind enum 和显式 scalar/object/array 访问器。禁止退回 any 或在公共 API 使用 Object 强制转换。"

这条"二选一"策略的聪明之处在于:它把"尝试递归别名"和"降级到 class 方案"都提前锁定了,避免在编译失败后临时讨论"怎么办"。阶段 1 编译一失败,立即切换,不再争论第三种方案。

2.3 arkts-no-untyped-obj-literals:对象字面量的限制

这条约束禁止用 { key: value } 字面量直接实现接口:

// ❌ 编译报错:arkts-no-untyped-obj-literals
const observer: StreamObserver<StreamingEvent> = {
  onNext: (e: StreamingEvent) => { /* ... */ },
  onError: (e: ArkAgentError) => { /* ... */ },
  onComplete: () => { /* ... */ }
}

// ✅ 必须用显式 class 实现
class MyObserver implements StreamObserver<StreamingEvent> {
  onNext(e: StreamingEvent): void { /* ... */ }
  onError(e: ArkAgentError): void { /* ... */ }
  onComplete(): void { /* ... */ }
}
const observer = new MyObserver()

这条约束对 JSON 的影响是:你不能用对象字面量构造 JSON DTO,必须走显式的 JsonObject + JsonValue.string(...) 工厂方法。写起来更啰嗦,但每个字段的类型在构造时就确定了。

2.4 arkts-limited-throw:错误必须是 Error 子类

// ❌ 不能 throw 字符串或自定义非 Error 对象
throw 'something went wrong'
throw { code: 'decode_error', message: '...' }

// ✅ 必须 throw Error 的子类
throw new Error('something went wrong')
throw new ArkAgentError(...)  // ArkAgentError extends Error

ArkAgent 的 ArkAgentError 明确继承 Error,源码注释直接写了原因:

/**
 * Throwable domain error. Extends Error so ArkTS `throw` is valid.
 */
export class ArkAgentError extends Error {

2.5 JSON.parse 返回的对象无法用 .xxx 访问

即使你写了 const data = JSON.parse(text),拿到的对象也无法用 data.choices 访问——因为 JSON.parse 的返回类型是 Object(或动态类型),ArkTS 不允许对它做属性访问。你必须自己解析,或者把它喂给类型安全的编解码器。

⚠️ ArkTS 严格模式的设计哲学:这五条约束的本质是——所有动态数据必须在边界主动建模,不能依赖运行时的鸭子类型。你可以处理 JSON,但必须用类型安全的方式;你可以有动态数据,但必须用受控的容器(JsonObject/JsonValue)承载。这换来的是编译期保证:凡是能编译通过的代码,类型就是对的。


三、JsonValue 三件套:class + enum + Map

面对 ArkTS 的约束,ArkAgent 设计了三个紧密配合的类型:JsonValue(值容器)、JsonKind(类型标签)、JsonObject(对象容器)。这三个类型定义在 core/Json.ets,一共只有 73 行,却是整个 SDK 的类型安全基石。

3.1 设计概览

┌───────────────────────────────────────────────────────────┐
│  JsonKind(枚举)                                          │
│    nullValue | stringValue | numberValue | booleanValue   │
│    objectValue | arrayValue                               │
├───────────────────────────────────────────────────────────┤
│  JsonObject(对象容器)                                    │
│    内部:Map<string, JsonValue>                           │
│    方法:set / get / has / keys / copy                    │
├───────────────────────────────────────────────────────────┤
│  JsonValue(值容器,sealed by design)                     │
│    kind: JsonKind  ← 唯一的运行时类型标签                  │
│    私有数据字段:stringData / numberData / ...             │
│    静态工厂:string() / number() / boolean() / object()…  │
│    访问器:asString() / asNumber() / asObject() / …       │
└───────────────────────────────────────────────────────────┘

3.2 JsonKind:六个类型标签

export enum JsonKind {
  nullValue = 'null',
  stringValue = 'string',
  numberValue = 'number',
  booleanValue = 'boolean',
  objectValue = 'object',
  arrayValue = 'array'
}

这六个值精确对应 JSON 规范的六种数据类型。枚举值用字符串而不是数字,是为了调试可读性——日志里看到 kind=objectValue 比看到 kind=4 直观得多。

3.3 JsonObject:用 Map 而不是索引签名

export class JsonObject {
  private readonly values: Map<string, JsonValue> = new Map<string, JsonValue>()

  set(key: string, value: JsonValue): JsonObject {
    this.values.set(key, value)
    return this    // ← 返回 this,支持链式调用
  }

  get(key: string): JsonValue | undefined {
    return this.values.get(key)
  }

  has(key: string): boolean {
    return this.values.has(key)
  }

  keys(): string[] {
    return Array.from(this.values.keys())
  }

  copy(): JsonObject {
    const result = new JsonObject()
    this.values.forEach((value: JsonValue, key: string) => result.set(key, value))
    return result
  }
}

为什么用 Map 而不是 Record 因为 Record<string, T> 在 ArkTS 里也会触发 arkts-no-indexed-signatures(它的底层就是索引签名)。阶段 2 的报告记录了这个发现:"Record/header map 被 arkts-no-indexed-signatures 拒绝"。Map 是 ArkTS 原生支持的集合类型,不依赖索引签名,所以它是承载动态键值对的正确容器。

链式 set 的设计让构造 JSON 更紧凑:

// 链式构造(紧凑)
const params = new JsonObject()
  .set('type', JsonValue.string('object'))
  .set('properties', JsonValue.object(new JsonObject()))
  .set('required', JsonValue.array([JsonValue.string('recordId')]))

// 如果不返回 this,就得这么写(啰嗦)
const params = new JsonObject()
params.set('type', JsonValue.string('object'))
params.set('properties', JsonValue.object(new JsonObject()))
params.set('required', JsonValue.array([JsonValue.string('recordId')]))

3.4 JsonValue:私有构造器 + 静态工厂

JsonValue 是整个体系的核心。它的设计借鉴了"标签联合体"(tagged union)的思想——用 kind 字段标记当前持有的类型,用私有字段存储实际数据:

export class JsonValue {
  readonly kind: JsonKind
  private readonly stringData?: string
  private readonly numberData?: number
  private readonly booleanData?: boolean
  private readonly objectData?: JsonObject
  private readonly arrayData?: JsonValue[]

  // 构造器是 private —— 外部不能 new JsonValue(...)
  private constructor(kind: JsonKind, stringData?: string, numberData?: number,
    booleanData?: boolean, objectData?: JsonObject, arrayData?: JsonValue[]) {
    this.kind = kind
    this.stringData = stringData
    this.numberData = numberData
    this.booleanData = booleanData
    this.objectData = objectData
    this.arrayData = arrayData
  }

  // 静态工厂:唯一的创建入口
  static nullValue(): JsonValue { return new JsonValue(JsonKind.nullValue) }
  static string(value: string): JsonValue { return new JsonValue(JsonKind.stringValue, value) }
  static number(value: number): JsonValue { return new JsonValue(JsonKind.numberValue, undefined, value) }
  static boolean(value: boolean): JsonValue {
    return new JsonValue(JsonKind.booleanValue, undefined, undefined, value)
  }
  static object(value: JsonObject): JsonValue {
    return new JsonValue(JsonKind.objectValue, undefined, undefined, undefined, value)
  }
  static array(value: JsonValue[]): JsonValue {
    return new JsonValue(JsonKind.arrayValue, undefined, undefined, undefined, undefined, value)
  }

  // 访问器:按类型取值
  asString(): string | undefined { return this.stringData }
  asNumber(): number | undefined { return this.numberData }
  asBoolean(): boolean | undefined { return this.booleanData }
  asObject(): JsonObject | undefined { return this.objectData }
  asArray(): JsonValue[] | undefined { return this.arrayData }
}

3.5 为什么构造器是 private

这是一个关键的设计决策。如果构造器是 public,外部就可能写出:

// 如果构造器 public,这种写法就合法了
const bad = new JsonValue(JsonKind.stringValue, undefined, 42)
//                            ↑ 声称是 string   ↑ 却塞了个 number

类型标签和数据不匹配,这是"伪类型安全"——编译能过,但运行时 asString() 返回 undefined(因为 stringData 没赋值),而 asNumber() 反而返回 42。这种 bug 极难排查。

把构造器设为 private,强制通过静态工厂创建,就保证了类型标签和数据永远一致

// 工厂方法内部保证 kind 和 data 对齐
static string(value: string): JsonValue {
  return new JsonValue(JsonKind.stringValue, value)        // kind=string, stringData=value ✓
}
static number(value: number): JsonValue {
  return new JsonValue(JsonKind.numberValue, undefined, value)  // kind=number, numberData=value ✓
}

每个工厂方法内部精确地只填入对应类型的数据字段,其他字段保持 undefined。这样 kind 就是绝对可信的类型标签——kind === JsonKind.stringValue 时,asString() 一定返回有效字符串。

3.6 访问器:用 undefined 表示类型不匹配

访问器的设计是"宽容返回"——如果类型不匹配,返回 undefined 而不是抛异常:

const value = JsonValue.string('hello')

value.kind        // JsonKind.stringValue
value.asString()  // 'hello'
value.asNumber()  // undefined ← 不抛异常,返回 undefined
value.asObject()  // undefined

为什么不抛异常?因为 JSON 数据是动态的,"某个字段可能是 string 也可能是 number"是合法场景(比如某些 Provider 的 id 字段有时是数字有时是字符串)。如果访问器抛异常,调用方就得写 try-catch,代码会很丑。返回 undefined 让调用方用可选链优雅处理:

const id = root.get('id')?.asString() ?? root.get('id')?.asNumber()?.toString() ?? ''

但"宽容访问"不代表"不校验"——校验逻辑在 DomainCodec 和各 Codec 层做,那里会对 undefined 做明确判断并返回 DecodeError。访问器只负责"取值",校验器负责"判断对不对",职责分离。


四、⚠️ 踩坑一:递归类型别名被编译器拒绝

这是整个 JSON 体系的"创世踩坑"——正是因为这次失败,才催生了 JsonValue class 方案。

4.1 症状

阶段 1 首日,按照阶段 0 JSON spike 的"首选方案",写下了递归类型别名:

export type JsonScalar = string | number | boolean | null
export type JsonValue = JsonScalar | JsonObject | JsonValue[]
export interface JsonObject {
  [key: string]: JsonValue
}

编译,直接报错:arkts-no-indexed-signaturesJsonObject 接口里的 [key: string]: JsonValue 被 ArkTS 编译器拒绝。

4.2 错误的尝试

尝试一:换成 Record 心想 Record<string, JsonValue> 也许不算索引签名?

export type JsonObject = Record<string, JsonValue>   // ❌ 同样被拒

结果:阶段 2 报告记录了这条路也走不通——"Record/header map 被 arkts-no-indexed-signatures 拒绝"。Record 的底层就是索引签名,换汤不换药。

尝试二:退回 any 既然类型搞不定,用 any 绕过:

const data: any = JSON.parse(text)   // ❌ arkts-no-any

结果:被 ADR-0003 的红线挡住——"公共 API 禁止 any"。而且这等于放弃类型安全,把所有错误推到运行期。

尝试三:用 Object 强制转换。

const data = JSON.parse(text) as Object   // 拿到 Object,然后呢?
// data.choices ← 还是不能属性访问

结果:Object 类型同样不支持动态属性访问,这条路是死胡同。spike 文档明确写了:"禁止退回 any 或在公共 API 使用 Object 强制转换"。

4.3 根因

ArkTS 严格模式的核心立场:动态数据必须有显式的类型建模,不能靠索引签名或 any 偷懒。递归类型别名 + 索引签名是 TypeScript 的惯用法,但它在 ArkTS 的类型安全模型里没有容身之地。这不是 bug,是设计选择——ArkTS 用编译期的严格,换取运行期的安全。

4.4 最终方案:JsonValue class + Map

三条路堵死后,唯一正确的方案就是阶段 0 spike 预先准备的降级方案:

// JsonKind 枚举作为类型标签
export enum JsonKind { nullValue, stringValue, numberValue, booleanValue, objectValue, arrayValue }

// JsonObject 用 Map 承载键值对(不依赖索引签名)
export class JsonObject {
  private readonly values: Map<string, JsonValue> = new Map<string, JsonValue>()
  // get / set / has / keys ...
}

// JsonValue 用 class + private 构造器 + 静态工厂
export class JsonValue {
  readonly kind: JsonKind
  private readonly stringData?: string
  // ... 其他类型字段
  private constructor(...) { ... }
  static string(v: string): JsonValue { ... }
  asString(): string | undefined { ... }
}

这个方案在阶段 1 通过编译和往返测试后,实验用的失败代码被删除(ADR-0003 记录:"实验文件已删除"),正式实现就是你现在看到的 core/Json.ets。风险登记册里这条(R-003)被标记为"已缓解"。

4.5 教训与预防

这次踩坑沉淀出两条工程规则:

  1. ArkTS 动态数据必须在边界主动建模,不能依赖 TypeScript 索引签名。如果你发现自己在写 [key: string],停下来——这条路在 ArkTS 走不通。

  2. 遇到编译约束,准备降级方案,而不是绕过约束。ArkAgent 在阶段 0 就预判了递归别名可能被拒,准备了 class 方案作为降级。编译一失败立即切换,不浪费时间争论。


五、手写递归下降 Parser:不依赖 JSON.parse

JsonValue 体系解决了"动态数据怎么建模"的问题。但还有个前置问题:JSON 文本怎么变成 JsonValue

标准答案是 JSON.parse。但在 ArkTS 里,JSON.parse 返回的是动态对象,你没法把它直接转成 JsonValue(那需要遍历动态对象,又回到索引签名问题)。ArkAgent 的解法:自己写一个递归下降 Parser,从字符到 JsonValue,全程类型安全

这个 Parser 定义在 core/JsonCodec.ets,是一个 200 行的私有类 JsonParser,加上导出的 JsonCodec 静态工具类。

5.1 JsonCodec:对外接口

先看对外的接口(业务代码只和这个打交道):

export class JsonCodec {
  // 解析 JSON 文本 → JsonValue(根可以是任意 JSON 类型)
  static parse(source: string): Result<JsonValue, ArkAgentError> {
    return new JsonParser(source).parse()
  }

  // 解析 JSON 文本 → JsonObject(根必须是对象,否则报错)
  static parseObject(source: string): Result<JsonObject, ArkAgentError> {
    const parsed = JsonCodec.parse(source)
    if (!parsed.ok || parsed.value === undefined) {
      return Result.failure<JsonObject, ArkAgentError>(parsed.error as ArkAgentError)
    }
    const object = parsed.value.asObject()
    if (object === undefined) {
      return Result.failure<JsonObject, ArkAgentError>(
        ArkAgentError.decode('json_expected_object', 'Expected JSON object'))
    }
    return Result.success<JsonObject, ArkAgentError>(object)
  }

  // 序列化 JsonValue → JSON 文本
  static stringify(value: JsonValue): string { ... }
}

三个要点:

第一,parseparseObject 都返回 Result,不抛异常。 解析失败时,你拿到的是一个包含 ArkAgentErrorResult.failure,而不是一个 try-catch 里捕获的异常。这让错误处理变成线性的 if (!result.ok) 判断,而不是嵌套的 try-catch。

第二,parseObjectparse 的特化版本。 它先调 parse 拿到 JsonValue,再检查 kind 是不是 objectValue,不是就报 json_expected_object 错误。业务代码绝大多数场景需要的是对象(Provider 响应、工具参数、状态文件),所以 parseObject 是最常用的入口。

第三,stringifyJsonValue 序列化回文本。 它是 parse 的逆操作,用于把领域模型编码后的 JsonObject 发送给 Provider 或写入持久化文件。

5.2 JsonParser:逐字符递归下降

JsonParser 是一个标准的递归下降解析器(recursive descent parser)。它维护一个 position 游标,逐字符扫描输入文本,根据当前字符决定解析逻辑:

class JsonParser {
  private readonly source: string
  private position: number = 0

  constructor(source: string) {
    this.source = source
  }

  parse(): Result<JsonValue, ArkAgentError> {
    this.skipWhitespace()
    const value = this.parseValue()
    if (!value.ok) return value
    this.skipWhitespace()
    // 解析完后,必须到文本末尾——不允许尾部有多余内容
    if (this.position !== this.source.length) {
      return Result.failure<JsonValue, ArkAgentError>(
        this.error('json_trailing_content', 'Unexpected trailing content'))
    }
    return value
  }

  private parseValue(): Result<JsonValue, ArkAgentError> {
    if (this.position >= this.source.length) {
      return Result.failure<JsonValue, ArkAgentError>(
        this.error('json_unexpected_end', 'Unexpected end of JSON'))
    }
    const token = this.source[this.position]
    if (token === '"') return this.parseStringValue()      // 字符串
    if (token === '{') return this.parseObject()           // 对象
    if (token === '[') return this.parseArray()            // 数组
    if (token === 't') return this.parseLiteral('true', JsonValue.boolean(true))
    if (token === 'f') return this.parseLiteral('false', JsonValue.boolean(false))
    if (token === 'n') return this.parseLiteral('null', JsonValue.nullValue())
    if (token === '-' || this.isDigit(token)) return this.parseNumber()
    return Result.failure<JsonValue, ArkAgentError>(
      this.error('json_unexpected_token', 'Unexpected token'))
  }
}

parseValue 是分发器——看第一个字符,决定走哪条解析路径。这种"首字符分发"是递归下降解析器的经典模式,直观且易于维护。

5.3 parseObject:对象的解析

对象解析最能体现这个 Parser 的设计:

private parseObject(): Result<JsonValue, ArkAgentError> {
  this.position++   // 跳过 '{'
  this.skipWhitespace()
  const object = new JsonObject()
  if (this.peek('}')) {   // 空对象 {}
    this.position++
    return Result.success<JsonValue, ArkAgentError>(JsonValue.object(object))
  }
  while (this.position < this.source.length) {
    // 1. 解析 key(必须是字符串)
    const keyResult = this.parseStringRaw()
    if (!keyResult.ok || keyResult.value === undefined) {
      return Result.failure<JsonValue, ArkAgentError>(keyResult.error as ArkAgentError)
    }
    this.skipWhitespace()
    // 2. 期望冒号
    if (!this.peek(':')) {
      return Result.failure<JsonValue, ArkAgentError>(
        this.error('json_expected_colon', 'Expected colon'))
    }
    this.position++   // 跳过 ':'
    this.skipWhitespace()
    // 3. 递归解析 value
    const valueResult = this.parseValue()
    if (!valueResult.ok || valueResult.value === undefined) return valueResult
    // 4. 放入 JsonObject
    object.set(keyResult.value, valueResult.value)
    this.skipWhitespace()
    // 5. 判断是结束还是继续
    if (this.peek('}')) {   // 对象结束
      this.position++
      return Result.success<JsonValue, ArkAgentError>(JsonValue.object(object))
    }
    if (!this.peek(',')) {  // 不是 '}' 就必须有 ','
      return Result.failure<JsonValue, ArkAgentError>(
        this.error('json_expected_comma', 'Expected comma'))
    }
    this.position++   // 跳过 ','
    this.skipWhitespace()
  }
  return Result.failure<JsonValue, ArkAgentError>(
    this.error('json_unclosed_object', 'Unclosed object'))
}

每一步都有明确的错误处理——期望冒号却没找到、期望逗号却没找到、对象没闭合,都会返回带 offset 信息的 ArkAgentError

5.4 字符串解析:转义和 Unicode

字符串解析是最复杂的部分,因为要处理转义序列:

private parseStringRaw(): Result<string, ArkAgentError> {
  if (!this.peek('"')) {
    return Result.failure<string, ArkAgentError>(this.error('json_expected_string', 'Expected string'))
  }
  this.position++   // 跳过开头的 '"'
  let result = ''
  while (this.position < this.source.length) {
    const character = this.source[this.position++]
    if (character === '"') {
      return Result.success<string, ArkAgentError>(result)   // 闭合引号
    }
    if (character === '\\') {   // 转义序列
      const escaped = this.source[this.position++]
      if (escaped === '"' || escaped === '\\' || escaped === '/') result += escaped
      else if (escaped === 'b') result += '\b'
      else if (escaped === 'f') result += '\f'
      else if (escaped === 'n') result += '\n'
      else if (escaped === 'r') result += '\r'
      else if (escaped === 't') result += '\t'
      else if (escaped === 'u') {   // \uXXXX Unicode 转义
        const hex = this.source.substring(this.position, this.position + 4)
        const code = parseInt(hex, 16)
        if (Number.isNaN(code)) {
          return Result.failure<string, ArkAgentError>(
            this.error('json_invalid_unicode', 'Invalid unicode escape'))
        }
        result += String.fromCharCode(code)
        this.position += 4
      } else {
        return Result.failure<string, ArkAgentError>(
          this.error('json_invalid_escape', 'Invalid escape'))
      }
    } else {
      result += character   // 普通字符
    }
  }
  return Result.failure<string, ArkAgentError>(
    this.error('json_unclosed_string', 'Unclosed string'))
}

完整的转义处理:\" \\ \/ \b \f \n \r \t \uXXXX。每种转义都有对应的还原逻辑。非法转义(比如 \x)会报 json_invalid_escape 错误。

5.5 ⚠️ 踩坑二:错误定位必须有 offset

症状:Provider 返回了一段畸形 JSON(比如 {"choices":[{"delta":{"content":"你好"}]},少了一个 }),SDK 报了"解析失败",但日志里只有 json_unclosed_object: Unclosed object,没有告诉你是哪个位置出错了。排查时对着一大段 JSON 手动找错,非常痛苦。

根因:最初版本的错误信息只有错误码和文字描述,没有带上 position(出错的字符位置)。

修复:在 error 方法里把 position 拼进错误信息:

private error(code: string, message: string): ArkAgentError {
  return ArkAgentError.decode(code, `${message} at offset ${this.position}`)
}

现在错误信息变成 json_unclosed_object: Unclosed object at offset 47,你直接去文本的第 47 个字符附近看,就能快速定位问题。

预防:所有解析错误必须携带 offset。这条规则不仅适用于 JSON Parser,也适用于 SSE 解析、UTF-8 解码等所有"逐字符/逐字节处理"的组件。错误定位信息是可调试性的基础。


六、解码四规则:从 JsonObject 到领域模型

JSON 文本变成 JsonObject 后,下一步是把 JsonObject 变成强类型的领域模型(AgentMessageToolCallModelResponse 等)。这个转换由 DomainCodec 负责,它遵循四条解码规则。

6.1 四条规则总览

规则

处理方式

示例

必填字段缺失或类型错误

返回 DecodeError

id 是必填但缺失 → 报错

未知字段

默认忽略(wire DTO 可保留到 extensions

Provider 返回了新字段 foo → 忽略

未知枚举

映射为 unknown,保留 raw value

新的 ContentKindunknown + raw 字符串

数字无效

返回 DecodeError(NaN/Infinity 拒绝)

NaN / Infinity → 报错

6.2 标量读取工具:Decode

DomainCodec 内部定义了一个 Decode 工具类,封装了四种标量的安全读取:

class Decode {
  // 必填字符串:缺失或类型错 → DecodeError
  static requiredString(object: JsonObject, key: string): Result<string, ArkAgentError> {
    const value = object.get(key)?.asString()
    if (value === undefined) {
      return Result.failure<string, ArkAgentError>(ArkAgentError.decode(
        'decode_required_string', `${key} is required and must be string`))
    }
    return Result.success<string, ArkAgentError>(value)
  }

  // 可选字符串:不存在 → undefined(合法);存在但类型错 → DecodeError
  static optionalString(object: JsonObject, key: string): Result<string | undefined, ArkAgentError> {
    if (!object.has(key)) return Result.success<string | undefined, ArkAgentError>(undefined)
    const value = object.get(key)?.asString()
    if (value === undefined) {
      return Result.failure<string | undefined, ArkAgentError>(ArkAgentError.decode(
        'decode_optional_string', `${key} must be string`))
    }
    return Result.success<string | undefined, ArkAgentError>(value)
  }

  // 可选数字:支持整数校验(integer=true 时检查是否有小数部分)
  static optionalNumber(object: JsonObject, key: string, integer: boolean = false): Result<number | undefined, ArkAgentError> {
    if (!object.has(key)) return Result.success<number | undefined, ArkAgentError>(undefined)
    const value = object.get(key)?.asNumber()
    if (value === undefined || !Number.isFinite(value) || (integer && !Number.isInteger(value))) {
      return Result.failure<number | undefined, ArkAgentError>(ArkAgentError.decode(
        integer ? 'decode_optional_integer' : 'decode_optional_number', `${key} has invalid number`))
    }
    return Result.success<number | undefined, ArkAgentError>(value)
  }

  // 可选布尔
  static optionalBoolean(object: JsonObject, key: string): Result<boolean | undefined, ArkAgentError> {
    if (!object.has(key)) return Result.success<boolean | undefined, ArkAgentError>(undefined)
    const value = object.get(key)?.asBoolean()
    if (value === undefined) {
      return Result.failure<boolean | undefined, ArkAgentError>(ArkAgentError.decode(
        'decode_optional_boolean', `${key} must be boolean`))
    }
    return Result.success<boolean | undefined, ArkAgentError>(value)
  }
}

关键区分:必填 vs 可选。

  • requiredString:字段不存在 → 报错(因为"必填")。

  • optionalString:字段不存在 → 返回 undefined(合法);字段存在但不是 string → 报错。

这个区分很重要。必填字段缺失是 Provider 违约,必须报错;可选字段缺失是正常的,可能是版本差异。但可选字段存在却类型错误仍然是错误——不能因为"可选"就容忍类型错误。

数字校验的两个层次: optionalNumber 有一个 integer 参数。integer=false(默认)只检查是否有限值(Number.isFinite),拒绝 NaNInfinityinteger=true 额外检查是否为整数(Number.isInteger),用于 index 这种必须为整数的字段。

6.3 解码 ToolCall:规则的综合应用

看一个完整的领域模型解码示例——ToolCall

static decodeToolCall(object: JsonObject): Result<ToolCall, ArkAgentError> {
  const id = Decode.requiredString(object, 'id')             // 必填
  const name = Decode.requiredString(object, 'name')         // 必填
  const argumentsJson = Decode.requiredString(object, 'argumentsJson')  // 必填
  const index = Decode.optionalNumber(object, 'index', true) // 可选,整数
  // 任一失败 → 短路返回第一个错误
  if (!id.ok || !name.ok || !argumentsJson.ok || !index.ok) {
    return Result.failure<ToolCall, ArkAgentError>(
      (id.error ?? name.error ?? argumentsJson.error ?? index.error) as ArkAgentError)
  }
  return Result.success<ToolCall, ArkAgentError>(new ToolCall(
    id.value as string, name.value as string, argumentsJson.value as string, index.value))
}

注意编码时 index 是可选的(if (call.index !== undefined)),但解码时用 optionalNumber——如果 JSON 里有 index 但不是整数,会报 decode_optional_integer 错误。这就是"可选字段存在却类型错误仍然是错误"的体现。

6.4 未知枚举:保留 raw value

这是四规则里最有远见的一条。以 ContentKind 为例:

static decodeContentPart(object: JsonObject): Result<ContentPart, ArkAgentError> {
  const rawKindResult = Decode.requiredString(object, 'kind')
  if (!rawKindResult.ok || rawKindResult.value === undefined) {
    return Result.failure<ContentPart, ArkAgentError>(rawKindResult.error as ArkAgentError)
  }
  const rawKind = rawKindResult.value
  // 逐个匹配已知枚举值,都不匹配 → unknown
  let kind = ContentKind.unknown
  if (rawKind === ContentKind.text) kind = ContentKind.text
  else if (rawKind === ContentKind.image) kind = ContentKind.image
  else if (rawKind === ContentKind.audio) kind = ContentKind.audio
  else if (rawKind === ContentKind.video) kind = ContentKind.video
  else if (rawKind === ContentKind.document) kind = ContentKind.document
  // ... 解码其他字段 ...
  return Result.success<ContentPart, ArkAgentError>(new ContentPart(
    kind, text.value, data.value, uri.value, mime.value, metadata,
    kind === ContentKind.unknown ? rawKind : undefined))
    //                       ↑ unknown 时保留 rawKind ↑
}

为什么不能直接报错? 因为 Provider 协议会升级。假设智谱明天加了一个新的 ContentKind(比如 audio),你的 SDK 还没更新。如果未知枚举直接报错,旧版 SDK 就完全无法处理新 Provider 的响应——整个 Agent 崩溃。

保留 raw value 的做法是:未知枚举映射为 ContentKind.unknown,同时把原始字符串存在 rawKind 字段里。这样 SDK 不会崩溃,业务可以决定是忽略还是提示"遇到了未知类型"。状态持久化时 raw value 也会被保存,恢复时不会丢失信息。

阶段 1 的实施心得把这条规则提炼成了一句经验:

"Unknown enum 必须同时保留 raw value,否则 Provider 协议升级会破坏状态回放。"

这条规则也是版本策略的一部分——minor 版本只追加可选字段或新枚举 raw fallback,不删除字段。有了 raw fallback,新旧版本的状态文件可以互操作。

6.5 encode/decode 对称性

每个领域模型都有成对的 encodedecode 方法:

// encode:领域模型 → JsonObject(用于持久化或发送)
static encodeToolCall(call: ToolCall): JsonObject {
  const object = new JsonObject()
    .set('id', JsonValue.string(call.id))
    .set('name', JsonValue.string(call.name))
    .set('argumentsJson', JsonValue.string(call.argumentsJson))
  if (call.index !== undefined) object.set('index', JsonValue.number(call.index))
  return object
}

// decode:JsonObject → 领域模型(用于加载或接收)
static decodeToolCall(object: JsonObject): Result<ToolCall, ArkAgentError> {
  // ... 见 6.3 ...
}

encode 不返回 Result(它不会失败——从强类型到 JsonObject 是安全的),decode 返回 Result(它可能失败——从动态数据到强类型需要校验)。这种不对称是合理的:编码是"我们信任自己的数据",解码是"我们不信任外部数据"。

往返测试(round-trip test)验证 encode/decode 的对称性:decode(encode(x)) === x。这是保证状态持久化正确性的基础——保存的状态一定能恢复。


七、Result + ArkAgentError:错误模型

整个 JSON 体系的错误处理建立在两个类型上:Result<T, E>ArkAgentError

7.1 Result:不抛异常的错误传递

export class Result<T, E> {
  private readonly valueData?: T
  private readonly errorData?: E
  readonly ok: boolean

  private constructor(ok: boolean, value?: T, error?: E) {
    this.ok = ok
    this.valueData = value
    this.errorData = error
  }

  static success<T, E>(value: T): Result<T, E> {
    return new Result<T, E>(true, value)
  }

  static failure<T, E>(error: E): Result<T, E> {
    return new Result<T, E>(false, undefined, error)
  }

  get value(): T | undefined { return this.valueData }
  get error(): E | undefined { return this.errorData }
}

Result 是一个标准的代数数据类型——要么是 success(携带值),要么是 failure(携带错误)。它用 ok 布尔标志区分两种状态,用泛型 TE 分别表示成功值和错误类型。

为什么用 Result 而不是 try-catch? 三个原因:

  1. ArkTS 的 throw 限制arkts-limited-throw 要求 throw 的必须是 Error 子类。如果你想让错误携带结构化信息(错误码、layer、retryability),用 Result 比自定义 Error 子类 + try-catch 更直接。

  2. 错误路径显式化Result 强制调用方处理错误——你不能"忘记 catch",因为 result.valueT | undefined,不检查 result.ok 就直接用 .value 会拿到 undefined。try-catch 允许你完全忽略异常(不写 catch 块),Result 不允许。

  3. 类型推断更好Result<T, ArkAgentError> 明确告诉你这个操作可能返回 TArkAgentError。try-catch 的异常类型在 ArkTS 里难以精确标注。

使用模式

const parsed = JsonCodec.parseObject(text)
if (!parsed.ok || parsed.value === undefined) {
  // 错误路径
  return Result.failure<...>(parsed.error as ArkAgentError)
}
// 成功路径
const object = parsed.value  // JsonObject

几乎每个 Codec 方法都是这个模式——先检查 ok,失败则短路返回错误,成功则继续。这种"错误短路"模式让代码线性可读,没有嵌套的 try-catch。

7.2 ArkAgentError:分层 + 可重试性

export enum ErrorLayer {
  decode = 'decode',       // JSON 解析、类型校验失败
  config = 'config',       // 配置错误(缺 Key、不支持的内容、未知扩展)
  transport = 'transport', // 网络传输错误
  provider = 'provider',   // Provider 返回的错误(401/429/500 等)
  tool = 'tool',           // 工具执行错误
  runtime = 'runtime',     // Runtime 内部错误
  storage = 'storage',     // 状态持久化错误
  security = 'security',   // 安全违规
  cancelled = 'cancelled'  // 用户取消
}

export enum ErrorRetryability {
  never = 'never',           // 永不重试(401、配置错误)
  safe = 'safe',             // 安全重试(幂等操作)
  conditional = 'conditional' // 条件重试(429/500,有退避)
}

export class ArkAgentError extends Error {
  readonly layer: ErrorLayer
  readonly code: string
  readonly retryability: ErrorRetryability
  readonly cause?: string
  readonly statusCode?: number
  readonly providerCode?: string
  readonly details?: JsonObject
  // ...
}

九个错误层覆盖了 SDK 的所有错误来源,每个都有明确的语义和重试策略:

ErrorLayer

含义

典型场景

默认重试性

decode

解析/校验失败

JSON 畸形、必填缺失、类型不匹配

never

config

配置错误

缺 Key、不支持的内容、未知扩展

never

transport

网络传输错误

连接失败、超时、DNS 错误

conditional

provider

Provider 返回错误

401/403/429/500

视状态码

tool

工具执行错误

工具抛异常、返回 isError

never(转 ToolResult)

runtime

Runtime 内部错误

状态机违规、循环检测

never

storage

状态持久化错误

文件读写失败、迁移失败

never

security

安全违规

Key 入日志、非 HTTPS

never

cancelled

用户取消

点停止、切后台取消

never

和 JSON 处理最相关的是 decodeconfig 两层:

  • decode 层:JSON 解析失败、必填字段缺失、类型不匹配。这类错误不可重试(同样的输入再试还是错)。

  • config 层:API Key 缺失、不支持的内容类型、未知 Provider 扩展。这类错误也不可重试(配置不变,结果不变)。

静态工厂让创建错误更简洁:

ArkAgentError.decode('json_expected_object', 'Expected JSON object')
ArkAgentError.config('missing_token', 'Bearer token is empty')
ArkAgentError.config('unknown_extension', `extensions.${key} is not allowed by provider profile`)

注意 decodeconfig 工厂默认 retryability = never——解析和配置错误天然不可重试。这避免了"401 错误被重试三次浪费时间"这种问题。


八、Provider extensions 守卫:不信任外部输入

JSON 类型安全的最后一道防线,是 Provider 扩展参数的守卫。这条规则看似和 JSON 无关,实则是"类型安全边界"理念的延伸。

8.1 问题:Provider 的语义漂移

智谱和 DeepSeek 都兼容 OpenAI 接口,但扩展参数不同:

扩展参数

智谱支持

DeepSeek 支持

thinking

tool_stream

reasoning_effort

如果用户给 DeepSeek 配了 tool_stream: true,会发生什么?DeepSeek 不认识这个参数,行为不可预测——有的 Provider 会报错,有的会忽略,有的会产生奇怪副作用。

8.2 ProviderExtensionGuard:白名单守卫

ArkAgent 的做法是:每个 Provider Profile 声明自己支持的扩展白名单,调用前校验,未识别的扩展报配置错误

export class ProviderExtensionGuard {
  static validate(request: ModelRequest, allowed: string[]): Result<void, ArkAgentError> {
    const extensions = request.modelConfig.extensions
    if (extensions === undefined) {
      return Result.success<void, ArkAgentError>(undefined as void)
    }
    const keys = extensions.keys()
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      if (allowed.indexOf(key) < 0) {
        return Result.failure<void, ArkAgentError>(
          ArkAgentError.config('unknown_extension',
            `ModelConfig.extensions.${key} is not allowed by provider profile`))
      }
    }
    return Result.success<void, ArkAgentError>(undefined as void)
  }

  // 按类型读取扩展(返回 undefined 表示不存在)
  static readObject(extensions: JsonObject | undefined, key: string): JsonObject | undefined {
    return extensions?.get(key)?.asObject()
  }
  static readString(extensions: JsonObject | undefined, key: string): string | undefined {
    return extensions?.get(key)?.asString()
  }
  static readBoolean(extensions: JsonObject | undefined, key: string): boolean | undefined {
    return extensions?.get(key)?.asBoolean()
  }
}

每个 Profile 在 capabilities 里声明自己的白名单:

// 智谱支持三个扩展
this.capabilities = new ProviderCapabilities(
  true, true, true, true, true, true,
  ['thinking', 'tool_stream', 'reasoning_effort'],   // ← 白名单
  ...)

// DeepSeek 只支持两个
this.capabilities = new ProviderCapabilities(
  true, true, true, true, true, false,
  ['thinking', 'reasoning_effort'],   // ← 没有 tool_stream
  ...)

8.3 为什么"不静默发送"

核心契约里有一条明确规则:

"Provider 特有参数仅进入 extensions,由 Provider Profile 消费;未消费的扩展必须在调用前报配置错误,不能静默发送。"

ADR-0008 的设计动机记录了原因:

"智谱与 DeepSeek 都宣称兼容 OpenAI,但请求扩展、thinking、Tool streaming、Usage 和错误存在差异。"

静默发送一个 Provider 不认识的参数,等于把控制权交给未知——你不知道 Provider 会报错、忽略还是产生副作用。与其让 bug 隐藏在"静默忽略"里,不如显式拒绝,让开发者在调用前就知道"这个参数不被这个 Provider 支持"。

这条规则把"Provider 语义漂移"的风险前移到了可观测、可处理的 config 错误层——而不是让它在运行期变成不可预测的行为。

8.4 Profile 怎么消费扩展

白名单校验通过后,各自的 Profile 用 readObject/readString/readBoolean 消费自己支持的扩展。智谱消费三个:

// ZhipuProviderProfile
applyRequestExtensions(body: JsonObject, request: ModelRequest): Result<void, ArkAgentError> {
  const extensions = request.modelConfig.extensions
  const thinking = ProviderExtensionGuard.readObject(extensions, 'thinking')
  if (thinking !== undefined) {
    body.set('thinking', JsonValue.object(thinking))
  }
  const toolStream = ProviderExtensionGuard.readBoolean(extensions, 'tool_stream')
  if (toolStream !== undefined) {
    body.set('tool_stream', JsonValue.boolean(toolStream))
  }
  const effort = ProviderExtensionGuard.readString(extensions, 'reasoning_effort')
  if (effort !== undefined) {
    body.set('reasoning_effort', JsonValue.string(effort))
  }
  return Result.success<void, ArkAgentError>(undefined as void)
}

DeepSeek 只消费两个(没有 tool_stream):

// DeepSeekProviderProfile
applyRequestExtensions(body: JsonObject, request: ModelRequest): Result<void, ArkAgentError> {
  const extensions = request.modelConfig.extensions
  const thinking = ProviderExtensionGuard.readObject(extensions, 'thinking')
  if (thinking !== undefined) {
    body.set('thinking', JsonValue.object(thinking))
  }
  const effort = ProviderExtensionGuard.readString(extensions, 'reasoning_effort')
  if (effort !== undefined) {
    body.set('reasoning_effort', JsonValue.string(effort))
  }
  return Result.success<void, ArkAgentError>(undefined as void)
}

注意 readObject/readBoolean/readString 都是类型安全的——它们内部用 asObject()/asBoolean()/asString() 访问器,类型不匹配返回 undefined(表示"这个扩展不存在或类型不对"),不会 crash。


九、类型安全的边界哲学

把前面的内容串起来,ArkAgent 的 JSON 类型安全体系可以画成一张边界图:

┌─────────────────────────────────────────────────────────────────┐
│  外部世界(Provider 响应、工具参数、状态文件)                       │
│  完全不信任,类型未知                                             │
└──────────────────────────┬──────────────────────────────────────┘
                           │ JSON 文本
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  边界一:JsonCodec.parseObject                                    │
│  手写 Parser,文本 → JsonObject                                   │
│  畸形 JSON → DecodeError(带 offset)                              │
└──────────────────────────┬──────────────────────────────────────┘
                           │ JsonObject(类型安全的动态容器)
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  边界二:DomainCodec.decodeXxx                                    │
│  JsonObject → 领域模型(ToolCall / Message / ...)                 │
│  必填缺失/类型错 → DecodeError                                     │
│  未知字段 → 忽略                                                   │
│  未知枚举 → unknown + raw value                                   │
│  数字无效 → DecodeError                                           │
└──────────────────────────┬──────────────────────────────────────┘
                           │ 强类型领域模型
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  边界三:ProviderExtensionGuard / ContentCapabilityGuard          │
│  领域模型 → Provider 请求                                          │
│  未知扩展 → config error(不静默发送)                              │
│  不支持的内容 → config error                                       │
└──────────────────────────┬──────────────────────────────────────┘
                           │ 校验通过的请求
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│  SDK 内部(Runtime / Loop / Tool)                                 │
│  全部使用强类型,零 any                                            │
└─────────────────────────────────────────────────────────────────┘

三层边界,每层都有明确的"信任转换":

  • 边界一:从不信任的文本,到类型安全的 JsonObject

  • 边界二:从动态的 JsonObject,到强类型的领域模型。

  • 边界三:从领域模型,到 Provider 特定的请求格式。

越过这三层边界后,SDK 内部全部是强类型代码,不需要再处理"字段可能不存在"或"类型可能不对"的问题——这些不确定性已经在边界上被消除了。

这就是 ArkAgent 的类型安全哲学:在边界上主动建模、主动校验、主动拒绝;在内部享受强类型的安全和简洁。不靠"小心一点",靠架构。


十、最佳实践清单

10.1 类型建模

  • ✅ 用 JsonValue class + JsonKind + JsonObject(Map) 作为动态数据的唯一载体。

  • JsonValue 构造器设为 private,通过静态工厂创建,保证 kind 和数据一致。

  • JsonObjectMap 而不是 RecordRecord 触发索引签名)。

  • ✅ 访问器返回 undefined 而不是抛异常,让调用方用可选链处理。

  • ❌ 不要用递归类型别名 + 索引签名([key: string]: T)。

  • ❌ 不要用 any 接收 JSON.parse 的结果。

  • ❌ 不要用 Object 强制转换动态对象。

10.2 JSON 解析

  • ✅ 用手写的严格 Parser,不依赖 JSON.parse 的动态结果。

  • ✅ 所有解析错误携带 offset 定位信息。

  • parse 返回 Result,不抛异常。

  • parseObject 校验根必须是对象。

  • ✅ 解析完后检查是否到文本末尾(拒绝尾部多余内容)。

10.3 领域编解码

  • ✅ 每个 decode 方法返回 Result<T, ArkAgentError>

  • ✅ 必填字段用 requiredString 等工具,缺失即报错。

  • ✅ 可选字段用 optionalString 等工具,不存在返回 undefined,类型错报错。

  • ✅ 数字校验有限值(Number.isFinite),整数额外校验(Number.isInteger)。

  • ✅ 未知枚举映射为 unknown + 保留 raw value。

  • ✅ 未知字段默认忽略。

  • ✅ encode/decode 对称,用往返测试验证。

10.4 错误模型

  • ✅ 用 Result<T, ArkAgentError> 传递错误,不靠 try-catch。

  • ✅ 错误必须继承 Error(满足 arkts-limited-throw)。

  • ✅ decode/config 错误默认 retryability = never

  • ✅ 错误信息不包含敏感数据(Key、Authorization、完整请求体)。

10.5 Provider 边界

  • ✅ 每个 Provider 声明扩展白名单。

  • ✅ 调用前用 ProviderExtensionGuard.validate 校验。

  • ✅ 未识别的扩展报 config error,不静默发送。

  • ✅ 模型名不进入 Core enum,作为配置字符串传入。

10.6 接口实现

  • ✅ 用显式 class 实现接口,不用对象字面量。

  • ✅ 测试 Fixture 优先用静态字符串,不用 JSON.stringify({...})


十一、常见错误对照表

错误做法

问题

正确做法

[key: string]: JsonValue

arkts-no-indexed-signatures 编译拒绝

JsonObject(内部 Map

Record<string, JsonValue>

同样触发索引签名

JsonObject(内部 Map

const data: any = JSON.parse(text)

arkts-no-any 编译拒绝

JsonCodec.parseObject(text)

data.choices[0].delta.content

动态对象无法属性访问

obj.get('choices')?.asArray()?.[0]?.asObject()?.get('delta')...

JSON.parse 后遍历动态对象

需要索引签名,编译拒绝

用手写 Parser 直接产出 JsonValue

{ onNext: fn, onError: fn }

arkts-no-untyped-obj-literals

用显式 class 实现接口

throw 'decode error'

arkts-limited-throw

throw new ArkAgentError(...) 或返回 Result.failure

未知枚举直接报错

Provider 升级旧 SDK 崩溃

映射为 unknown + 保留 raw value

必填字段缺失返回 null

和"值为 null"混淆

返回 DecodeError,区分"缺失"和"null"

可选字段类型错也忽略

静默吞错,难排查

存在但类型错仍报 DecodeError

NaN / Infinity 当数字

破坏后续计算

Number.isFinite 校验

整数字段接受浮点数

index: 1.5 无意义

Number.isInteger 校验

给 DeepSeek 配 tool_stream

静默发送,行为未知

白名单校验 + config error

未识别扩展静默忽略

Provider 行为不可预测

ProviderExtensionGuard.validate 拒绝

解析错误不带 offset

排查困难

${message} at offset ${position}

decode 失败 throw 异常

调用方要 try-catch

返回 Result.failure

encode 也返回 Result

从强类型到 JsonObject 不会失败

encode 直接返回 JsonObject


十二、验证清单(真机 Review)

编译验证

  • 全项目零 any

  • arkts-no-indexed-signatures 告警

  • arkts-no-untyped-obj-literals 告警

  • 所有 throw 的类型是 Error 子类

JSON 解析

  • 正常 JSON 解析正确

  • 畸形 JSON(少括号、少引号)返回带 offset 的 DecodeError

  • 尾部多余内容报 json_trailing_content

  • Unicode 转义(\uXXXX)正确解析

  • 所有转义序列(\n \t \r \b \f \/ \\ \")正确解析

  • 非法转义报 json_invalid_escape

  • parseObject 对非对象根报 json_expected_object

领域编解码

  • 必填字段缺失返回 DecodeError

  • 未知字段被忽略,不报错

  • 未知枚举映射为 unknown + raw value 保留

  • 整数字段拒绝浮点数

  • NaN/Infinity 被拒绝

  • encode → decode 往返一致(round-trip test)

Provider 扩展

  • 智谱 thinking/tool_stream/reasoning_effort 正确注入请求

  • DeepSeek thinking/reasoning_effort 正确注入请求

  • 给 DeepSeek 配 tool_streamunknown_extension config error

  • 扩展类型不匹配(比如 thinking 传字符串)被安全忽略或报错

状态持久化

  • 保存的状态文件能完整恢复

  • 未知字段的状态文件能加载(前向兼容)

  • 未知 ContentKind 的状态能恢复并保留 raw value

  • schemaVersion 存在且正确


十三、构建验证

# 构建 agent_core HAR
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemon

# 运行单元测试(含 JSON 编解码、领域 codec 往返测试)
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw test --no-daemon

构建结果:

CompileArkTS passed
BUILD SUCCESSFUL

JSON 类型安全体系的测试覆盖:正常 JSON 解析、畸形 JSON(每种错误码)、未知字段、未知枚举 raw fallback、数字边界(NaN/Infinity/整数)、encode/decode 往返一致性。这些测试用纯字符串 Fixture,不依赖真机网络,保证了类型安全逻辑的可复现验证。

风险登记册(R-003)记录的"ArkTS 递归 JSON 类型不通过严格编译"风险已通过 JsonValue class 方案已缓解,阶段 1 通过编译验证和往返测试关闭。


十四、写在最后

在 ArkTS 里处理 JSON,你会被五条编译约束(arkts-no-anyarkts-no-indexed-signaturesarkts-no-untyped-obj-literalsarkts-limited-throwJSON.parse 限制)同时逼到墙角。最直觉的"绕过"(anyESObject、强制转换)都是技术债——它们把编译期能抓住的问题推迟到运行期 crash。

ArkAgent 的选择是正面设计一套类型安全体系:

JSON 文本
  ↓ JsonCodec.parseObject(手写 Parser,带 offset 报错)
JsonObject(Map 承载,不依赖索引签名)
  ↓ DomainCodec.decodeXxx(四规则校验)
强类型领域模型(ToolCall / Message / ModelResponse)
  ↓ ProviderExtensionGuard(白名单守卫)
Provider 请求(未识别扩展不静默发送)

这条路的核心判断是:动态数据必须在边界主动建模,不能依赖运行时的鸭子类型JsonValue class + JsonKind + JsonObject(Map) 看起来比 TypeScript 的递归别名啰嗦,但它换来的是编译期保证——凡是能编译通过的代码,类型就是对的。在 AI Agent 这种"大量动态 JSON 交互"的场景下,这个代价极其划算。

记住这几条口诀:

ArkTS 禁 any 禁索引,递归别名走不通。 JsonValue 加 JsonKind,私有构造工厂建。 JsonObject 用 Map 装,链式 set 写得顺。 JSON.parse 不可靠,手写 Parser 带偏移。 必填缺失要报错,未知字段放心略。 未知枚举 unknown 兜,raw value 必须留。 数字校验有限值,整数不能带小数。 Result 不抛裸异常,错误分层带可重试。 Provider 扩展白名单,未识别的不静默。 边界建模内部纯,类型安全靠架构。

本文是 ArkAgent 鸿蒙教程系列的第五篇。前三篇讲了架构全景、快速接入和流式输出,这篇拆解了支撑这一切的类型安全基石——JSON 体系。后续文章会逐层深入工具调用、状态持久化、记忆子 Agent、评估框架——每一层的正确性都建立在这套类型安全体系之上。

如果你正在鸿蒙上做涉及大量 JSON 交互的应用(不只是 AI Agent,任何和后端 API 交互的应用),可以把这套 JsonValue 体系和三层边界模型直接作为你的类型安全基线。不要从 JSON.parse + any 开始——那是运行期 crash 的开始。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐