【共创稿事节】HarmonyOS 7 Agent 开发实战 08:结构化输出解析

在「HarmonyOS 7 Agent 开发实战」系列里,第 01 篇我们已经搭好了 llmclient,让模型能够稳定地"吐文字";第 03 篇讲了 Tool Calling,让模型能"发起工具调用"。本篇(第 08 篇)聚焦一个看似微小、却贯穿所有落地的环节:当模型吐出的不是散文,而是一段 JSON 时,你怎么把它变成可信、可校验、可落库的字段? 这就是"结构化输出解析"。

先划一条清晰的分界线。第 03 篇的 Tool Calling 解决的是"模型要不要调用某个函数、传什么参数"的问题------本质是动作决策 ;本篇解决的是"模型已经给了一段结构化文本,我怎么把它安全地解析成对象"的问题------本质是数据抽取与校验 。前者是让 Agent 能"做事",后者是让 Agent 给的数据"可用"。两者互补:你可以先让模型用 Tool Calling 决定调用 extractResume,再用本篇的解析器把返回结果校验落库。

一、为什么模型吐出的 JSON 不能直接 JSON.parse

理想情况下,你让模型"返回 JSON",它就会返回干净的一行 {...}。现实里,模型输出通常带着三层"脏":

  1. Markdown 围栏 :````````json ... ````包裹,甚至围栏语言标识写成`````json` 也可能写成别的。
  2. 前后碎碎念:开头一句"好的,以下是结果",结尾一句"以上信息已提取"。
  3. 语法瑕疵 :最后一个字段后面多一个尾逗号 { "a": 1, },这在标准 JSON 里是非法的,但模型非常爱写。

如果直接 JSON.parse,第二、第三点会立刻抛 SyntaxError。更危险的是第一点:围栏里的反引号进了字符串,解析同样失败。生产环境里这类"脏输出"出现率不低,靠 prompt 约束只能降低概率,不能消除。所以我们要在客户端做一层确定性的修复与校验,而不是赌模型每次都工整。

例如 ArkTS 中 JSON.parse 在输入非法时抛出 SyntaxError,因此我们把它包在 try/catch 里做安全解析;开发者联盟文档也建议所有外部来源的反序列化都应做容错处理。

二、整体架构:四段式管线

我们把"脏 JSON → 可信对象"拆成四个职责单一的纯函数模块,彼此不耦合,方便单测与复用:

  • JsonRepair:只负责文本修复与抽取(去围栏、去尾逗号、抠片段、安全解析)。
  • StructuredParser:消费 JsonRepair 的结果,按 schema 做字段存在性与类型校验。
  • MockLlmClient :实现统一的 LlmClient 抽象,离线返回一段脏 JSON,让整个 Demo 不依赖密钥即可跑通。
  • Index 页面:把文本框 + 解析按钮 + 结果展示串起来。

这条管线的好处是:JsonRepair 与 StructuredParser 都不碰网络、不碰设备,因此可以原样在 Node 里镜像一份纯逻辑做"预跑",也能直接进 Hypium 单元测试。验证前移,出问题就被挡在最前面。

三、核心一:JsonRepair 修复器

stripMarkdownFences 的逻辑分两步:先用正则把 ````````...````围栏的内容抠出来;若文本里压根没围栏,就找第一个`````{或[`,把之前的所有碎碎念裁掉。

typescript 复制代码
export class JsonRepair {
  static stripMarkdownFences(text: string): string {
    let s: string = text;
    const fence = /```[a-zA-Z]*\s*([\s\S]*?)```/g;
    const m = fence.exec(s);
    if (m !== null) {
      s = m[1];
    }
    const firstObj: number = s.indexOf('{');
    const firstArr: number = s.indexOf('[');
    let start: number = -1;
    if (firstObj >= 0 && firstArr >= 0) {
      start = Math.min(firstObj, firstArr);
    } else if (firstObj >= 0) {
      start = firstObj;
    } else if (firstArr >= 0) {
      start = firstArr;
    } else {
      return s.trim();
    }
    s = s.substring(start);
    return s.trim();
  }
}

removeTrailingCommas 用一个正则把"逗号紧跟 } / ]"的结构替换掉,像 { "a": 1, } 会变成 { "a": 1 }。

typescript 复制代码
static removeTrailingCommas(text: string): string {
  return text.replace(/,(\s*[\}\]])/g, '$1');
}

extractFirstJson 更关键:从一段很脏的文本里找到第一个 { 或 [,然后用括号计数法向后扫描,遇到字符串内的括号要跳过、遇到转义也要跳过,直到计数归零,截出那段完整 JSON。它保证即便 JSON 后面还跟着"谢谢使用"也不会被误吞。

safeParse 把 JSON.parse 包进 try/catch,成功返回 { ok: true, value },失败返回 { ok: false, error },绝不向上抛。这是整条管线"可降级"的基石。

四、核心二:StructuredParser 与 SchemaField

光解析还不够,我们还要校验 。定义一个极简的字段清单 SchemaField,描述每个字段的名字、期望类型、是否必填:

typescript 复制代码
export type SchemaFieldType = 'string' | 'number' | 'boolean' | 'object' | 'array';

export interface SchemaField {
  name: string;
  type: SchemaFieldType;
  required: boolean;
}

StructuredParser.parse 把四步串起来:修复 → 抽取 → 解析 → 逐字段校验。校验规则只有两条:必填字段缺失就判失败;字段存在但 typeof 不匹配就判失败(注意 NaN 不算合法 number,避免模型写出 "age": NaN 漏过)。

typescript 复制代码
export class StructuredParser {
  static parse(text: string, schema: SchemaField[]): ParseResult {
    let cleaned: string = JsonRepair.stripMarkdownFences(text);
    cleaned = JsonRepair.removeTrailingCommas(cleaned);
    const json: string = JsonRepair.extractFirstJson(cleaned);
    if (json.length === 0) {
      return { ok: false, error: '未找到合法的 JSON 对象或数组' };
    }
    const parsed = JsonRepair.safeParse(json);
    if (!parsed.ok) {
      return { ok: false, error: 'JSON 解析失败: ' + (parsed.error ?? '未知错误') };
    }
    const value: Record<string, Object> = parsed.value as Record<string, Object>;
    for (const field of schema) {
      const present: boolean = value[field.name] !== undefined;
      if (field.required && !present) {
        return { ok: false, error: '缺少必填字段: ' + field.name };
      }
      if (present && !StructuredParser.typeMatches(value[field.name], field.type)) {
        return { ok: false, error: '字段类型不符: ' + field.name + ' 期望 ' + field.type };
      }
    }
    return { ok: true, value };
  }
}

为了业务侧少写样板,我们提供两个类型化抽取器 :extractResume 与 extractContract,各自内置一份 schema。它们把"解析 + 校验"收敛成一个函数调用,页面只拿结果。

typescript 复制代码
static extractResume(text: string): Record<string, Object> {
  const schema: SchemaField[] = [
    { name: 'name', type: 'string', required: true },
    { name: 'title', type: 'string', required: false },
    { name: 'skills', type: 'array', required: true },
    { name: 'years', type: 'number', required: false }
  ];
  const r: ParseResult = StructuredParser.parse(text, schema);
  if (!r.ok) {
    return { __error: r.error ?? '解析失败' } as Record<string, Object>;
  }
  return r.value as Record<string, Object>;
}

五、离线可跑:MockLlmClient 与演示页

为了让 Demo 不需要任何密钥也能演示全链路 ,我们实现了一个 MockLlmClient,它继承自统一抽象 LlmClient,sendOnce 直接返回一段"带围栏 + 尾逗号 + 碎碎念"的脏 JSON:

typescript 复制代码
export class MockLlmClient extends LlmClient {
  cannedResponse: string;
  constructor(cannedResponse?: string) {
    super({ baseUrl: '', apiKey: '', model: '' } as LlmClientConfig);
    this.cannedResponse = cannedResponse ?? MOCK_RESUME_JSON;
  }
  buildPayload(_req: ChatRequest): Record<string, Object> { return {}; }
  buildHeaders(): Record<string, string> { return {}; }
  classifyError(_err: Error): RetryableError { return RetryableError.NON_RETRYABLE; }
  protected async sendOnce(_req: ChatRequest): Promise<string> {
    return JSON.stringify({
      choices: [{ message: { content: this.cannedResponse }, finish_reason: 'stop' }]
    });
  }
}

Index.ets 的演示页很简单:一个文本框预填脏 JSON,一个"解析"按钮,点下去调用 StructuredParser.extractResume,成功就把字段逐行展示,失败就把 error 文案高亮出来。整个交互纯本地、纯同步(修复与校验都是纯函数),所以即便在模拟器之外你也能用 Node 镜像把同样的逻辑跑通。

测试效果图如下:

六、避坑指南

  • 别只靠 prompt 约束:"请只返回 JSON"能降概率,但生产要把修复当必选项,而不是可选项。
  • 尾逗号是头号杀手 :模型几乎必写,正则去尾逗号要同时覆盖 {...} 与 [...] 两种闭合。
  • 字符串里的括号别误判 :extractFirstJson 必须识别字符串与转义,否则 "note": "a } b" 会让计数提前归零。
  • 校验要区分"缺失"与"类型错" :两者都返回 ok: false,但错误文案不同,前端才能给出精准提示。
  • NaN 不算 number :typeof NaN === 'number',校验时必须额外排除,否则脏数据会漏网。
  • 别把解析失败当异常抛 :用 { ok, value, error } 的结构返回,业务层按 ok 分支即可,崩溃面更小。

七、验证前移:Hypium 测试与 Node 镜像

因为 JsonRepair 与 StructuredParser 都是纯函数,我们写两套验证:一是 DevEco 里的 Hypium 单测,覆盖去围栏、去尾逗号、抠片段、合法解析、缺必填、类型不符六类用例;二是工程根目录的 verify-node.cjs,把同样逻辑用纯 JS 镜像一份,在本地 Node 里"预跑",不需要打开 IDE 就能确认算法正确。

纯逻辑通过了,再谈 UI 与设备。这一层不依赖网络、不依赖模拟器,是整套方案里最稳、也最该先写测试的部分。

八、何时该上结构化解析,何时不用

结构化输出解析不是银弹,它的适用边界也值得说清:

  • 适合:模型输出要落库、要进表单、要驱动后续逻辑。例如从简历抽字段、从合同抽金额与签约方、从工单抽优先级与标签。凡是"机器要消费"的输出,都该经过这一层。
  • 不必强求:纯展示给"人看"的散文、闲聊、解释性回答,直接渲染文本即可,强行 JSON 化反而损失表达力。
  • 与 01 篇的关系:第 01 篇的 llmclient 保证"能拿到模型文本",本篇在此基础上保证"拿到的文本能变成可信数据",两者是上下游;没有 01 的底座,本篇没有输入,没有本篇,01 的输出只能停在字符串层面。
  • 与 03 篇的协作 :如果业务需要先"决定抽什么",用 Tool Calling 让模型选 extractResume 或 extractContract;选完之后,本篇负责把那份输出解析校验。动作决策与数据校验,顺序接力,各管一段。

把这套"脏 JSON → 修复 → 校验 → 可信对象"的管线沉淀成公共模块后,新业务接结构化输出几乎零成本:写一份 schema,调一个 extractXxx,剩下的脏活都由 JsonRepair 与 StructuredParser 兜住。


参考与出处


最后一句:当模型学会"说话"之后,能不能把话变成可信的数据,才是 Agent 真正落地的最后一公里------而结构化输出解析,就是这条路上最值得先修好的一块路基。

相关推荐
donk13142 小时前
React Native for OpenHarmony 实战:三方库 react-native-segmented-control-tab 的鸿蒙化适配指南
javascript·react native·react.js·华为·harmonyos
360智汇云3 小时前
鸿蒙开发中如何实现画中画
华为·harmonyos
donk13144 小时前
React Native for OpenHarmony 实战:三方库 react-native-toast-message 的鸿蒙化适配指南
react native·react.js·音视频·harmonyos
Flutter_OH4 小时前
Flutter OHOS Dart 堆栈符号化
harmonyos
Flutter_OH5 小时前
Flutter OHOS Flutter 多设备自适应 DPI 指导文档
harmonyos
SuperHeroWu75 小时前
【HarmonyOS 7】 应用 Agent 接入云 A2A 与端 A2A 的实践详解
ai·harmonyos·小艺
用户29469405448166 小时前
个人开发者上架鸿蒙 AI 应用,最后倒在安全评估实名制这一格
harmonyos·deepseek
Keya6 小时前
HarmonyOS 开发实战:用 util.Aspect 玩转 AOP 切面编程
harmonyos
威哥爱编程7 小时前
HarmonyOS 7 Agent 开发实战 03:Tool Calling 让 AI 查实时订单与库存
agent·harmonyos