智能体在鸿蒙应用里输出结构化结果时,最常见的故障不是模型不会说,而是应用接不住:返回值可能包在 Markdown 围栏里,出现 Python 风格的 True/None、尾逗号、中文弯引号、缺逗号,或者在 max_tokens 截断后留下未闭合的字符串和数组。直接 JSON.parse 会抛错,待办和工具参数随之中断。AgentJsonKit 的做法是把 jsonrepair v3.15.0 移植到 ArkTS 严格模式,在端侧把残缺文本修成可解析 JSON,并针对短文本流式预览、大文本 TaskPool 执行和 ArkUI 页面坑做工程化处理。
一、场景与目标:端侧修复要分三条路径
上游 jsonrepair 锁定的版本是 v3.15.0,对应提交 4a80ed8,ISC 许可。它能处理单引号、缺逗号、截断、Markdown 代码块、Python 关键字、未加引号的键、注释、弯引号和 NDJSON,也带有按块喂入、最后 flush 的 streaming 核心。AgentJsonKit 当前版本 0.1.0,示范应用包名 com.example.agentjsonkit,compatibleSdkVersion 为 6.0.0(20),targetSdkVersion 为 26.0.0。页面样本全部本地生成,不申请网络权限,也不调用模型。
修复分成三条路径:regular 路径一次性修完整段文本;streaming 核心按块喂入;IncrementalRepairer 面向界面增量预览。大文本则交给 TaskPool,避免长时间占用 UI 线程。这样划分的原因是上游 streaming 核心会保留 64 KB 回看缓冲,小文档在 flush 之前可能根本不回调 onData,界面无法靠它逐 token 刷新。
二、增量预览:短文本边收边修,但要接受 O(n²) 代价
IncrementalRepairer 的逻辑不复杂:每来一个 token,就把它叠到当前前缀上;达到节流阈值后调用一次 jsonrepair;修成功就更新快照,暂时失败就沿用上一份,并带上 fresh 和 lastError。它只适合短文本,总代价约为 O(n²) 次字符处理。主机基准里,8 KB 语料逐 token 增量修复的中位耗时约 1395 ms;内置 Markdown 样本只有 274 个字符、95 个 token,同一口径约 1.51 ms,用来刷新界面够用,更大的档位没有拿它去测。
- append(token: string): RepairSnapshot | null {
- this.text += token;
- if (this.text.length - this.lastRepairAt < this.minCharsBetweenRepairs) {
- return null;
- }
- return this.snapshot();
- }
- snapshot(): RepairSnapshot {
- this.lastRepairAt = this.text.length;
- try {
- this.lastGood = jsonrepair(this.text);
- this.lastGoodChars = this.text.length;
- return new RepairSnapshot(this.lastGood, this.lastGoodChars, true, '');
- } catch (e) {
- const msg = e instanceof Error ? (e as Error).message : String(e);
- return new RepairSnapshot(this.lastGood, this.lastGoodChars, false, msg);
- }
- }
复制代码
三、regular 路径的性能问题:用 OutputBuilder 避免整段反复拷贝
上游 regular 路径把整份输出放在一个字符串里,每次去掉末尾逗号,或在最后一个空白前插入字符,都会把整段再拷一遍。修复点一多,成本就是平方级。移植时加了 OutputBuilder:新内容先写进较短的可变 tail,超过 8192 个字符后把前面冻结进 parts,尾部仍留 1024 个字符方便就地编辑;只有当一次编辑必须碰到已冻结的部分时,才把整体拼回来再改。语义与上游 regular 对齐,由差分测试保证。
- append(text: string): void {
- this.tail += text;
- if (this.tail.length > TAIL_FLUSH_AT) {
- const cut = this.tail.length - TAIL_KEEP;
- this.parts.push(this.tail.substring(0, cut));
- this.headLength += cut;
- this.tail = this.tail.substring(cut);
- }
- }
复制代码
主机基准中,同一份语料上 512 KB 从上游约 4.9 s 降到约 64 ms,1 MB 从约 17.7 s 降到约 127 ms,曲线接近线性。4 MB 档的最小到最大值:移植 regular 是 512–635 ms,移植 streaming 是 14066–14462 ms。streaming 核心仍按上游保留 64 KB 回看缓冲,大文档上慢于本移植 regular,但仍快于上游 regular。这些数字来自 2026-11-13 北京时间在 Node v24.14.1(Command Line Tools 自带 V8)上的测试,不代表鸿蒙方舟运行时。
四、ArkTS 严格模式移植:语义对齐比修 JSON 更花时间
上游 regular 解析器是闭包风格,内层函数共享游标和输出字符串。ArkTS 严格模式不允许嵌套函数声明,于是把同一套语义改写成私有类 JsonRepairer,用字段代替被捕获的变量,对外仍只暴露 jsonrepair()。流式核心同样改写成类 JsonRepairCore。输入缓冲原先是工厂函数返回带方法的对象,ArkTS 不允许这种对象字面量,于是改成 InputBuffer 类。
更隐蔽的是越界下标。上游大量代码依赖 text === undefined 表示读到末尾;移植后统一走 charAt,越界得到的是空串。问题在于 ''.includes('') 为 true,如果不先挡住空串,越界位置会被当成属于某些分隔符,修复路径就会走错。因此判断字符集合必须加长度条件:
- function isOneOf(chars: string, c: string): boolean {
- return c.length === 1 && chars.includes(c);
- }
复制代码
抛错信息也要对齐上游。原文在读到末尾时打印的是 JSON.stringify(undefined) 的结果,也就是字面量 undefined;charAt 得到空串时,要把这句文案补回去。ArkTS 也没有结构类型,上游空白判断可以接受任何带 charCodeAt 的对象,移植时只能拆成面向 string 的 isWhitespace 和面向码点的 isWhitespaceCode,流式核心走后者。工具函数里的正则字面量改成预先构造的 RegExp,转义字符表这类索引对象改成 Map。
五、streaming 核心的两处上游问题
移植 streaming 核心时撞上 v3.15.0 的两处问题。官方测试集行为没有改;此后 regular 路径仍以 npm jsonrepair@3.15.0 为对照,这两处不再拿上游 streaming 做逐字节 oracle。
第一处,输入类似 {q'[ 时,flush() 会在补逗号和去逗号之间死循环。[ 可以开始一个值,但不能开始一个对象键,而上游只特殊处理了 {。移植时把 [ 与 { 同等看待,直接闭合当前对象,与 regular 解析器一致。
第二处,输入 0,{q:1,{ 时,上游会剥离缓冲区里最后一次出现的逗号,即使它并不在输出末尾,结果变成非法 JSON。改法是只有逗号确实拖在输出末尾时才剥离。两处都修在 parseRepairObjectEndOrComma 里,提交为 dbaa3f1,并加了回归测试。
- const c = this.input.charAt(this.i);
- if (c === '{' || c === '[') {
- if (this.output.endsWithIgnoringWhitespace(',')) {
- this.output.stripLastOccurrence(',');
- }
- this.output.insertBeforeLastWhitespace('}');
- return this.stack.pop();
- }
复制代码
六、大文本交给 TaskPool,ArkUI 组件还要防 getter 踩空
语言能编译、结果能对齐之后,大文本仍然不能摊在主线程上。示范应用把修复放到 TaskPool,任务内部仍调用 regular jsonrepair,并检查 JSON.parse 是否成功。
- @Concurrent
- export function repairLargeText(text: string): LargeRepairResult {
- return repairAndMeasure(text);
- }
- const task = new taskpool.Task(repairLargeText, text);
- r = await taskpool.execute(task, taskpool.Priority.HIGH) as LargeRepairResult;
复制代码
示范应用第一次装到 HarmonyOS 7 模拟器时,HAP 在首屏崩溃,错误是 Cannot read property fault of undefined。原因是组件里写了 getter,ArkUI 在第一次 build 时没有让这个 getter 按预期工作,this.sample 实际是 undefined,模板再读 .fault 就触发 jscrash。提交 2ca5538 把它改成普通方法 currentSample(),并加上边界判断。这类问题只有设备侧才会暴露,主机侧把 .ets 当 TypeScript 跑覆盖不到 ArkUI 的组件语义。
模拟器上 100 KB 档曾记录 TaskPool 修复 185 ms、端到端 308 ms、心跳最大间隔 34 ms;紧接的主线程对照修复 169 ms、端到端 171 ms,但心跳达到 211 ms 并被标红。另一次记录中 TaskPool 期间心跳 74 ms,主线程 211 ms。同一档 TaskPool 修复还出现过 436 ms 和 185 ms 的波动,所以这些毫秒数只说明路径能跑通,以及主线程是否被堵住能从心跳上看出来,不当作性能结论。
七、验证边界:主机差分通过不等于方舟通过
仓库验证记录在 docs/VERIFY.md。2026-11-13 北京时间的主机测试环境是 Node v24.14.1;官方 176 组加外挂 33 条,共 209 组用例跑通。外挂覆盖 7 个故障样本、按 token 喂入的 streaming 与 regular 一致、增量快照收敛、节流与失败回退、ArkTS charAt 语义、示范模型含 1 MB 修复、对 npm jsonrepair@3.15.0 的 1000 条 seeded regular 差分(文档大小 2–42 KB)、streaming 终止性、两处上游 streaming 回归,以及 512 KB、1 MB、4 MB 大文档。
但差分测试是在 V8 上跑的,用来保证和上游 v3.15.0 语义一致,也保证 OutputBuilder 没有改结果,不等于方舟运行时验证。正则、String.fromCodePoint 等仍需设备侧 hypium。当前 hypium 用例能够编译,但在 Linux 上用 Previewer 跑本地测试会挂起,出不了结果;这次也没有在模拟器上把 hypium 套件完整跑完,库逻辑靠示范页在方舟上实际跑过,再加 V8 差分交叉保证。示范应用声明支持 phone、tablet 和 2in1,但现有走查只覆盖 HarmonyOS 7.0.0(API 26)的手机模拟器 AgentPhone。
八、结论:先把端侧接住,再谈更复杂的智能体界面
对鸿蒙应用来说,更现实的做法不一定是再做一个会聊天的演示,而是把智能体吐出来的、常常不太合法的结构化结果,在端侧修成真正用得起来的 JSON:可以一次修完,可以按块流式修,短文本边收边看,大文本交给 TaskPool。当前 AgentJsonKit 仍是 0.1.0 的 HAR 库加可运行示范应用,ohpm 包名还是占位,ohpm 组织名尚未确定,仓库里已验证的是本地 file 依赖写法;许可证声明也有待完全对齐。设备侧 hypium 和方舟同口径性能复测补齐后,这套库才更有把握被其他鸿蒙应用直接复用。 |