开源鸿蒙三方库适配实践:把 jsonrepair 搬到 ArkTS,端侧修好大模型吐出的坏 JSON

在鸿蒙应用里接大模型,最烦的是模型吐出来的 JSON 经常是坏的:包着 Markdown 围栏、带着 Python 的 True、写到一半被 max_tokens 截断。JSON.parse 一报错,后面的卡片和工具调用就全卡住了。
我把成熟的 jsonrepair 移植成了 ArkTS 严格模式版本 AgentJsonKit,结论先放这:
- **和上游语义一致:**官方测试集 176 条全部通过,另加 33 条自己的用例。
- **大文本快了一百多倍:**1 MB 修复从约 17.7 秒降到约 127 毫秒(Node 主机基准,方舟上待复测)。
- **能边流边修:**token 还在往外吐,界面上就能看到结构树在长。
过程中还顺手修了上游 streaming 核心的两个边界 bug。
AtomGit 仓库:https://atomgit.com/lwcwam/agent-json-kit
GitHub 镜像:github.com/LucianaiB2004/agent-json-kit
一、为什么要把 jsonrepair 搬到鸿蒙上
在鸿蒙应用里接大模型的结构化输出,最常见的场面是:模型已经开始往外吐字了,本地拿 JSON.parse 一接,直接报错。原因五花八门。有的把 JSON 包在 Markdown 代码块里;有的带着 Python 的 True、None,后面还拖个尾逗号;有的用了中文弯引号,或者两个对象之间漏了逗号。最多的还是写到一半被 max_tokens 截断,括号根本没闭合。偶尔还会一次吐出好几行,其实是 NDJSON。只要解析一失败,后面的卡片、待办、工具参数就全卡住了。

这类问题的边角情况太多,与其自己写一堆正则兜底,最后变成另一个不完整的修复库,不如直接用现成的。josdejong 的 jsonrepair 已经处理得比较全:单引号、缺逗号、截断、Markdown 围栏、Python 关键字、没加引号的键、注释、弯引号、NDJSON 都能修,还带一个按块喂入、最后再 flush 的 streaming 核心。修复要是放在 Node 后端,装原包就完事了。可我想要的是端侧修复:在设备上本地把 JSON 修好,最好 token 还在往外流的时候,界面上就能看到一棵结构树在长。
麻烦在于这个库是写给 TypeScript / Node 的,搬过来不是复制粘贴那么简单。ArkTS 严格模式没有 Node 的 stream.Transform,嵌套函数、带方法的对象字面量也都不让写。上游 streaming 核心默认要留 64 KB 回看缓冲,小文档在 flush() 之前可能一次 onData 都不回调,拿它做逐 token 刷新行不通。regular 路径又会把输出字符串一遍遍整段拷贝,文档一大、要修的地方一多,耗时就按平方往上涨。就算修得够快,把 MB 级文本放在 UI 线程里同步跑,页面照样会卡。


所以我给这次适配划的范围很具体:v3.15.0 的语义原样迁到 ArkTS 严格模式;补一个给界面用的增量预览;大文本修复丢进 TaskPool;再做一个不联网、不用 API Key 的示范 HAP,拿本地样本把整条路走通。
二、环境与版本
这次移植的基准是 josdejong/jsonrepair v3.15.0(ISC,提交 4a80ed8),打成 HAR 放进示范应用验证,项目叫 AgentJsonKit,现在是 0.1.0。下面的版本信息我是对着工程文件和 docs/VERIFY.md 抄的,没有凭记忆写。
| 项 | 值 |
|---|---|
| 仓库 / 应用名 | AgentJsonKit 0.1.0(versionCode 1000000),包名 com.example.agentjsonkit |
| 上游原作 | josdejong/jsonrepair v3.15.0,提交 4a80ed8,ISC |
| 语言与模型 | ArkTS 严格模式 / Stage |
| compatibleSdkVersion | 6.0.0(20)(API 20) |
| targetSdkVersion | 26.0.0(API 26) |
| 构建工具 | Command Line Tools 26.0.0.865(hvigor 6.26.9,SDK API 26,ohpm 26.0.0.630),JDK 21 |
| 工程 runtimeOS | HarmonyOS |
| 工程声明的设备类型 | phone / tablet / 2in1 |
| 已验证环境 | HarmonyOS 7.0.0 手机模拟器 AgentPhone(hdc 127.0.0.1:11111),2026-11-13(北京时间) |
| HAR 模块 | jsonrepair 0.1.0;ohpm 包名目前仍是占位,组织名未定,是否已发布到 ohpm 仓库待确认 |
关于页顶部能看到同一个版本号。先交代一句:文中所有界面截图都是在这台 HarmonyOS 7 手机模拟器上截的,不是真机。

三、移植步骤:我是按什么顺序做的
先搭差分测试环境
我没有先碰界面。第一件事是搭一个能随时回答「现在和上游还一不一致」的对照环境,不然后面每改一行心里都没底。
这个环境就是仓库里的 tools/upstream-difftest/。做法有点土:把 .ets 拷成 .ts,直接在 Node 的 V8 上跑。regular 路径拿 npm 上的 jsonrepair@3.15.0 当标准答案,官方测试集放在 run/*.test.ts。好处是不用等方舟运行时,就能先知道我有没有把语义改坏。HAR 和 HAP 则用上面那套 Command Line Tools 来编:hvigorw --mode module -p product=default assembleHar assembleHap --no-daemon。
再改 ArkTS 严格模式语法
然后才轮到改语法。上游 regular 解析器是闭包写法,一堆内层函数共享同一个游标和输出字符串。ArkTS 严格模式不允许嵌套函数声明,编译一跑就会报出来。我把整套逻辑改成私有类 JsonRepairer,原来被捕获的变量都变成字段,对外仍然只暴露 jsonrepair()。streaming 核心也照这个办法改成了 JsonRepairCore。输入缓冲原本是工厂函数返回一个带方法的对象,这种对象字面量 ArkTS 同样不认,就改成了 InputBuffer 类。正则字面量换成预先构造好的 RegExp,转义字符表这类索引对象换成 Map。
最别扭的是结构类型。上游判断空白的写法,只要传进来的东西带 charCodeAt 就能用,字符串和输入缓冲共用一份。ArkTS 没有结构类型,这招用不了,我只好拆成两个函数:面向 string 的 isWhitespace,和面向码点的 isWhitespaceCode,流式核心用后者。
最后补性能和界面
能编译只是开始。接下来我先让官方集的 176 条在 V8 上全部跑绿,再补上针对 ArkTS 语义、示范模型和模糊测试的外挂用例。移植 streaming 核心时,我在上游 v3.15.0 里碰到两处问题(第五节细说),修掉以后,这两处就不再拿上游 streaming 做逐字节对照了;regular 路径仍然以 npm jsonrepair@3.15.0 为准。大文本上,上游 regular 会把输出整段反复拷贝,是平方级的,我加了一个 OutputBuilder 来解决,语义照样靠差分测试兜着。
库能用了,还得让应用用得上。上游 streaming 要攒回看缓冲,小文档往往要等到 flush() 才出结果,界面等不起,所以我另写了 IncrementalRepairer,专门给短文本做边收边看的预览。示范应用就是 entry 这个 HAP,底部三个页签:流式输出、大文本、关于。它不申请网络权限,不调用任何模型,七个故障样本都是我在本地手工构造的。大文本修复放进 TaskPool,免得堵住 UI 线程。
做完回头看,仓库分成三块:jsonrepair HAR、entry 示范应用,还有主机上的差分测试和基准工具。修复路径也有三条:一次性的 regular、按块喂入的 streaming、给界面用的增量预览。哪种场合该走哪条,我画成了下面这张图。

四、关键代码:我改了上游哪些地方
先看调用方式。对使用者来说,一次性修复和上游完全一样:
import { jsonrepair, JSONRepairError } from 'jsonrepair';
try {
const out: string = jsonrepair("{'name': '鸿蒙', ok: True,}");
// {"name": "鸿蒙", "ok": true}
} catch (e) {
if (e instanceof JSONRepairError) {
// e.message、e.position
}
}
接口没变,时间都花在里面了。
最容易悄悄改坏语义的,是越界下标。上游很多地方靠 text[i] === undefined 判断读到了末尾;我这边统一用 charAt,越界拿到的是空串 ''。问题是 ''.includes('') 为 true,成员判断会被这个空串骗过去。所以「是不是这几个字符之一」必须先把空串挡掉:
function isOneOf(chars: string, c: string): boolean {
return c.length === 1 && chars.includes(c);
}
报错文案也得跟着对齐。上游读到末尾时,打印的是 JSON.stringify(undefined) 的结果,也就是字面上的 undefined。charAt 给的是空串,我得在拼错误信息时手动换回 undefined,不然差分测试在「末尾异常」这类用例上就对不上。
IncrementalRepairer 是我为界面预览补的,上游没有。思路很直接:每来一个 token 就拼到已收到的前缀后面,攒够节流阈值(构造参数 minCharsBetweenRepairs,默认 1)就对整个前缀调一次 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);
}
}
OutputBuilder 也是这次新加的,专门治 regular 路径的平方级拷贝。新写入的内容先进一段可变的 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);
}
}
streaming 核心里还有两处({q'[ 和 0,{q:1,{),我有意和上游 v3.15.0 不一样,放到第五节一起讲。
五、问题与解决:踩过的几个坑
空串这个坑,我觉得比 undefined 本身还阴。少了 c.length === 1 这一层,越界的位置会被当成「属于这些分隔符」,修复就走进了另一条分支,而且不会报错。所以我在主机侧专门加了用例,确认越界空串既不会被当成分隔符,也不会被当成数字。
streaming 在 flush() 里挂住,是我事先没想到的。移植 streaming 核心时,我在上游 v3.15.0 里碰到两处问题,都在 parseRepairObjectEndOrComma 里。我有意改得和上游不一样,官方测试集的行为没动。
一处是输入像 {q'[ 这样时,flush() 会在「补逗号」和「去逗号」之间来回打转,停不下来。这种输入不是官方测试集关注的重点。原因是 [ 能开始一个值,却开始不了对象的键,而上游只特殊处理了 {。我让 [ 和 { 走同一条路,直接闭合当前对象,regular 解析器本来就是这么做的。
另一处是 0,{q:1,{,更隐蔽。上游会去删缓冲区里最后出现的那个逗号,哪怕它根本不在输出末尾,于是函数正常返回,结果却是非法的 [0{"q":1},{}]。我改成只有逗号确实拖在末尾时才删。两处修改都在提交 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();
}
这件事让我意识到,官方集全绿不等于没问题,同一份古怪输入,streaming 和 regular 并不总是一样稳。
示范应用第一次装到 HarmonyOS 7 模拟器上,首屏就崩了,报 Cannot read property fault of undefined。查下来是组件里的这个 getter:
private get sample(): FaultSample {
return FAULT_SAMPLES[this.sampleIndex];
}
第一次 build 的时候,这个 getter 没按我预期工作,this.sample 拿到的是 undefined,模板再去读 .fault,直接 jscrash。我在提交 2ca5538 里把它换成普通方法 currentSample(),顺手加了边界判断。后来页面拆到 StreamPanel.ets,这个写法也一直留着。这种问题只有上了设备才会暴露:主机侧把 .ets 当 TypeScript 跑,碰不到 ArkUI 的组件语义。
这也是我一直提醒自己的:差分测试跑在 V8 上,能保证和上游 v3.15.0 语义一致、OutputBuilder 没改结果,但它不是方舟运行时的验证。正则、String.fromCodePoint 这些在方舟上表现如何,还得靠设备侧的 hypium,这部分还没跑完(见文末「已知限制」)。目前库逻辑靠的是两头交叉:示范页在方舟上实际跑过,再加上 V8 上的差分测试。
我也回头查过测试本身灵不灵。之前有报告说,去掉 isOneOf 的空串防护会让测试失败,复查后发现并不成立:防护还在,只是当时没有用例覆盖到它。反过来,对 streaming 核心的关键字做突变,差分测试会失败,说明这张网至少是敏感的。
六、编译、验证和运行截图
验证记录在 docs/VERIFY.md 里,是 2026-11-13(北京时间)跑的,主机测试用的是 Node v24.14.1:
| 测试项 | 结果 |
|---|---|
| HAR + HAP 未签名构建 | 成功;UI 重设计后 ArkTS 编译 0 警告 |
| 未签名 HAP 体积 | 配图缩放并量化为 256 色 PNG 后,约 5.7 MB 降到约 2.3 MB |
codelinter . | 无缺陷报告;重设计后为 0 错误,另有 6 条自定义组件拆分的性能建议 |
| 上游官方测试集 | 176 / 176 通过 |
| 外挂用例 | 33 / 33 通过 |
合计 npm test | 209 / 209 通过(7 个文件) |
| 未签名 HAP 安装到 HarmonyOS 7 模拟器 | 成功,三个页签可打开;修复 getter 崩溃之后,不再出现新的 jscrash |
外挂的 33 条主要盯这几件事:7 个故障样本修出来是合法 JSON,按 token 喂给 streaming 的结果和 regular 一致,增量快照最终能收敛;节流和失败回退;ArkTS charAt 的越界语义;示范模型,包括 1 MB 修复;拿 1000 份带种子生成的文档(2–42 KB)和 npm jsonrepair@3.15.0 的 regular 做差分;streaming 能否正常结束;前面那两处 streaming 回归;还有 512 KB、1 MB、4 MB 的大文档。
功能这块,我在模拟器里一页一页点过去。样本一共七种:Markdown 围栏、输出被截断、Python 风格、带注释的 JS 对象、中文弯引号、缺逗号和省略号,还有连续几段 NDJSON。
打开流式页,选中 Markdown 样本,计数是 0/95,还没开始发 token。

点「开始流式输出」并不会去连大模型:本地的 MockLlmStream 用固定种子把文本切成 1 到 6 个字符的伪 token,再用定时器往外发。播放参数在下面可调,我用的是 60 毫秒一枚、每 1 个字符修一次。

token 一到,IncrementalRepairer 就去修已经攒下的前缀,结构树跟着往下长。播到一半大概是这样:

播完之后,同一批 token 会再喂一遍 jsonrepairCore,和一次性 jsonrepair() 的结果做字符串比较。Markdown 样本跑到 95/95,一致性通过,共 264 个字符,结构树里是「周末杭州一日游」;96 次快照里有 2 次是修失败后回退到了上一份。

切到格式化 JSON 再点复制,提示「已复制 346 字符 JSON」,多出来的是换行和缩进。

不想看逐 token 播放,就点「立即修复」:不分片,对整段直接做一次 jsonrepair()。我换成「输出被截断」这个样本试了一下。


大文本页有 100 KB、1 MB、4 MB 三档,点下去会现场生成一份带单引号、True / None、尾逗号和截断尾巴的非法 JSON,可以选 TaskPool 修,也可以放在主线程里做对照。下面的圆环由 16 毫秒的定时器驱动,主线程一被占住,间隔就会拉长。注意 TaskPool 的「端到端」包含跨线程传输,主线程对照不含。

100 KB 这一档,TaskPool 那次修复 185 ms、端到端 308 ms、心跳最大间隔 34 ms,输出为合法 JSON;主线程对照修复 169 ms、端到端 171 ms,心跳却拉到了 211 ms,被标成红色。这些毫秒数都是页面当次测的,模拟器上每次波动较大,只看量级,不拿它下性能结论:它说明这条路径在方舟上能跑通,主线程有没有被堵住,从心跳上看得出来。


关于页下半部分又把架构列了一遍,也写明了文档里的主机基准是 V8 数据,不代表方舟。

说快慢之前,我得先把口径讲清楚。docs/BENCHMARK.md 里的数字是 2026-11-13(北京时间)在 Node v24.14.1(Command Line Tools 自带的 V8)上测的,不是 HarmonyOS 方舟运行时。语料用的是示范应用里同一个生成函数:单引号、True / None、尾逗号、末尾截断。1 KB 到 1 MB 每档预热 1 次、跑 7 次取中位数;4 MB 预热 1 次、跑 5 次取中位数。上游 regular 在这份语料上是平方级的,耗时太长,4 MB 档默认跳过。下面这张图是同一组中位数,用对数坐标画的。IncrementalRepairer 没画进去,它本来就只管短文本。4 MB 那一档只在后面的表里。

| 目标大小 | 实际字符 | 上游 npm jsonrepair@3.15.0(中位) | 本移植 regular(中位) | 本移植 streaming(中位) | IncrementalRepairer(中位) |
|---|---|---|---|---|---|
| 1 KB | 1089 | 0.46 ms | 0.60 ms | 0.73 ms | 23.5 ms |
| 8 KB | 8258 | 1.14 ms | 0.98 ms | 1.45 ms | 1395 ms |
| 64 KB | 65655 | 39.1 ms | 8.70 ms | 40.6 ms | (未测,前缀重修复代价过高) |
| 128 KB | 131130 | 303 ms | 16.5 ms | 277 ms | (未测) |
| 256 KB | 262221 | 1261 ms | 30.9 ms | 726 ms | (未测) |
| 512 KB | 524403 | 4918 ms | 63.7 ms | 1748 ms | (未测) |
| 1 MB | 1048656 | 17688 ms | 127 ms | 3672 ms | (未测) |
| 4 MB | 4194414 | 未测(平方级,耗时过长) | 536 ms | 14415 ms | (未测) |
4 MB 档的最小到最大值:移植版 regular 是 512–635 ms,移植版 streaming 是 14066–14462 ms。我最在意的是中间几行:同一份语料,512 KB 从上游的约 4.9 s 降到约 64 ms,1 MB 从约 17.7 s 降到约 127 ms,基本接近线性了,这正是 OutputBuilder 不再整段反复拷贝带来的。streaming 核心按上游保留了 64 KB 回看缓冲,大文档上比移植版 regular 慢,但还是比上游 regular 快。
七、接入实际项目:示范应用怎么用这个 HAR
示范应用没有另包一层演示 API,就是把 HAR 当本地依赖直接引进 entry。ohpm 包名目前还是占位(见文末「已知限制」),确定能用的是本地路径写法:
// entry/oh-package.json5
{
"dependencies": {
"jsonrepair": "file:../jsonrepair"
}
}
大文本页的修复放在 TaskPool 里。RepairTask.ets 用 @Concurrent 包住测量函数,LargePanel.ets 再把它扔进线程池。任务里调用的仍是 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;
八、已知限制
老实说,这个项目离「做完」还有距离,目前已知的限制有这几条:
- **ohpm 包名还是占位:**库的 README 里写了
ohpm install jsonrepair,但oh-package.json5注明包名只是占位,ohpm 组织名还没定,能不能从 ohpm 仓库直接装到待确认。目前请用上面的本地路径依赖。 - 许可证没对齐:
jsonrepair库按 ISC 发布,保留了 Jos de Jong(2020–2026)的版权声明,也注明了这次移植;仓库根 README 写其余代码也是 ISC,可entry/oh-package.json5的 license 字段目前是 Apache-2.0,以哪份为准待确认。 - **hypium 还没跑完:**设备侧用例能编译,但在 Linux 上用 Previewer 跑本地测试会挂住,还没在模拟器或真机上跑出完整结果。
- **平板和 2in1 没**走查**:**工程声明了 phone / tablet / 2in1,我只在手机模拟器上走查过。
- **方舟上的性能复测没做:**文中基准都是 Node v24.14.1(V8)上的数据,和它同口径的方舟复测得等上真机。示范页只能给出当次实测,波动也不小:同样是 100 KB 的 TaskPool 修复,模拟器上就记过 436 ms 和 185 ms 两个数。
这几件补上之后,我才敢说这个 HAR 可以直接拿给别的开源鸿蒙或 HarmonyOS 应用用。
如果你也在端侧接大模型的结构化输出,欢迎去仓库看看,哪里不好用,直接提 issue 告诉我。
AtomGit 仓库:https://atomgit.com/lwcwam/agent-json-kit
GitHub 镜像:github.com/LucianaiB2004/agent-json-kit
更多推荐

所有评论(0)