1. 前言
楼主平时算是个"单位困难户":烤箱温度说华氏度、跑步配速用英里、海淘价格是英镑、菜谱写一杯牛奶。每次卡住都是掏手机解锁搜索等结果,一套下来十几秒。我正戴着Rokid眼镜呢,为什么不张嘴问一句"100华氏度是多少摄氏度",让AI眼镜直接给答案?
于是楼主在Rokid AIUI Studio上做了"随身换算":对着眼镜说一句换算的话,页面打开就是结果,左边原始数值,右边一个大字结果,页面底下三个按钮:反向换算、换一组示例、算一个。

整个思路一句话总结就是,固定单位的日常换算,用一张系数表维护在本地,不需要调用模型、也不涉及联网存储。
2. 项目背景
AIUI Studio里的智能体,一个app.json声明名称、触发说法和权限,一个.ink页面文件写界面和逻辑,脚本是标准JavaScript。楼主的做法是:解析和换算全部收进modules/convert.js,modules/voice.js负责控制收音,pages/convert/convert.ink管展示,页面自己一个数都不算。app.json里pages只挂convert一个页面,标题"随身换算",permissions全文件就RECORD_AUDIO一项,没有网络权限,没有存储权限。
agentPrompt楼主拆成四层:第一层给触发例句,六组单位各点一个名,从"100华氏度是多少摄氏度"到"1英亩是多少平方米";第二层定参数怎么传,有明确数值和单位就传value、fromUnit、toUnit,只有原话就传text;第三层把20个中文单位名列全,宿主不用猜;第四层是纪律------缺单位、超出支持范围或只说"度"时页面会提示,不要替用户猜测单位。为什么不调模型?20个固定单位的换算本质就是一张系数表加一个温度公式,大模型来算又慢又贵还可能编个系数,本地一张表快、稳、轻量。
3. 界面与操作
页面按480×352布局,黑底荧光绿,从上到下:分组标签、方向文字、左右两个数值框、状态行、三个按钮、底部提示。结果字号分三档:超过十个字26像素,超过七个字34像素,其余44像素,换算结果通常就几个字符,平时看到的基本都是最大字号。
「反向换算」把当前结果当成新的原始值换回去;「换一组示例」在八组预设示例里循环,八组把六组单位各覆盖到;「语音说」进收音流程,后面单说。按过反向换算之后是这样:

状态行是这个页面的"解说员",从"已按宿主传入的数值换算""已解析原话并换算"到"已反向换算""示例N / 8"各有台词,出错显示"提示:"加具体原因,涉及货币结尾自动补" · 参考汇率,非实时"。台词有42个字截断上限,普通状态半透明荧光绿,警告状态实色加粗。空打开时回落第一组示例,状态行提示"示例换算,可以说"3英里是多远""。键盘也能用:确认键触发焦点按钮,上下键换焦点,左右键故意没接------三个按钮横着排,左右键容易和滚动手势打架。
4. 单位设计
六个分组二十个单位:温度(摄氏度、华氏度)、长度(公里、英里、米、英尺)、重量(公斤、磅)、货币(英镑、人民币)、体积(升、毫升、加仑、立方米、杯)、面积(平方米、平方英尺、英亩、公顷、亩)。温度之外每个单位只存一个"对基准的系数",基准是每组里最常用的那个,任意两组单位之间换算都是先乘源系数到基准、再除以目标系数,20个单位只要存18个系数加2个温度函数,不用维护20乘19的互转大表,往后加单位也只动一行。全表贴在这,"也认这些说法"一栏是页面能听懂的全部叫法:
| 分组 | 单位 | 也认这些说法 | 对基准的系数 | 默认换到 |
|---|---|---|---|---|
| 温度 | 摄氏度 | 摄氏、°c、c | 基准 | 华氏度 |
| 温度 | 华氏度 | 华氏、°f、f | 仿射,见下节 | 摄氏度 |
| 长度 | 公里 | 千米、km | 1000米 | 英里 |
| 长度 | 英里 | mile、mi | 1609.344米 | 公里 |
| 长度 | 米 | 公尺、m | 基准 | 英尺 |
| 长度 | 英尺 | 呎、ft、feet | 0.3048米 | 米 |
| 重量 | 公斤 | 千克、kg | 基准 | 磅 |
| 重量 | 磅 | lb、lbs、pound | 0.45359237公斤 | 公斤 |
| 货币 | 英镑 | gbp、£ | 9.18元(参考值) | 人民币 |
| 货币 | 人民币 | 元、rmb、cny、¥ | 基准 | 英镑 |
| 体积 | 升 | l、L | 基准 | 加仑 |
| 体积 | 毫升 | ml、mL | 0.001升 | 升 |
| 体积 | 加仑 | 美加仑、gal、gallon | 3.78541升 | 升 |
| 体积 | 立方米 | m3、m³ | 1000升 | 升 |
| 体积 | 杯 | cup | 0.236588升 | 毫升 |
| 面积 | 平方米 | m2、m²、平米 | 基准 | 平方英尺 |
| 面积 | 平方英尺 | 平方呎、sqft、ft2、ft² | 0.092903平方米 | 平方米 |
| 面积 | 英亩 | acre、ac | 4046.86平方米 | 公顷 |
| 面积 | 公顷 | ha | 10000平方米 | 英亩 |
| 面积 | 亩 | 市亩 | 666.667平方米 | 平方米 |
系数有讲究的交代两句:英里1609.344、英尺0.3048、磅0.45359237都是国际标准的精确值;加仑按美制3.78541升;杯按美制量杯折的0.236588升;英镑那格是参考汇率,不是计量值。最后一列是默认目标,"3英里是多远"只提到一个单位也知道往哪换,两个单位都提了就按用户说的来。
单位名匹配先把别名表建好,查表前做NFKC归一化再全部转小写,全角半角、大小写都不至于失配。"度"是个特例,单独映射成歧义标记,解析时看另一半单位:另一半是温度单位就自动补全,两边都是"度"或另一半不是温度就抛出追问。楼主的原则是只挑日常高频不贪全:二十个够覆盖九成场景,再往里堆维护和测试的负担都会涨。
5. 温度换算
温度单独说,因为它是表里唯一不能"乘个系数"搞定的一组:华氏度和摄氏度的关系是 (F减32) 乘5除9,那个32是冰点偏移,换算前要先平移,所以这对单位在表里不是factor,是一对toBase和fromBase函数,原文是:
css
fahrenheit: {group: 'temperature', label: '华氏度', aliases: ['华氏度', '华氏', '°f', 'f'],
toBase: v => (v - 32) * 5 / 9, fromBase: v => v * 9 / 5 + 32}
摄氏度做基准,别的十八个单位走同一条factor通道,页面上感觉不到差别。温度还有一条别的组没有的底线:绝对零度。区间检查用的是换算成基准单位之后的值,基准值低于零下273.15摄氏度,直接报"温度不能低于绝对零度(零下273.15摄氏度)"。上限各组分两档,温度最高10万摄氏度,其余五组放到1亿,都是"日常换算"的合理边界;其他分组只挡负数,温度挡的是物理下限。
仿射和系数两套路子,最后都汇进同一个换算主函数convertValue:查表、挡跨组、挡边界、进基准、出目标,一共十一行,原文抄在这:
vbnet
export function convertValue(value, fromId, toId) {
const from = UNITS[fromId];
const to = UNITS[toId];
if (!from || !to) throw new Error('单位暂不支持,支持:' + UNIT_LABELS);
if (from.group !== to.group) throw new Error(from.label + '和' + to.label + '属于不同类型,不能互转');
const num = Number(value);
if (!Number.isFinite(num)) throw new Error('数值不是有效的数字');
const base = from.toBase(num);
checkRange(from.group, base);
return to.fromBase(base);
}
6. 边界划分
做单位换算的时候最怕的不是不认识,是自作聪明,楼主定的规矩:拿不准就明说,绝不猜。最典型的是"度":口语里"40度"可能是摄氏度也可能是华氏度,页面直接追问是哪一种;但"100度是多少摄氏度"问题本身就排除了摄氏度,"度"就按华氏度处理得37.78,不用用户重复。不认识的单位也一样直说,"3光年等于多少米"返回"不认识"加单位,后面跟着支持单位清单;跨组混搭如米换磅,返回"属于不同类型,不能互转"。
"度"的补全、追问和"不认识"的拒绝,本体都在resolveUnit里,原文抄在这:
ini
function resolveUnit(raw, otherRaw) {
const key = normalizeUnit(raw);
if (!key) return null;
const hit = ALIAS_MAP[key];
if (hit === AMBIGUOUS_DEGREE) {
const otherKey = normalizeUnit(otherRaw);
const otherId = otherKey ? ALIAS_MAP[otherKey] : null;
if (otherId && otherId !== AMBIGUOUS_DEGREE && UNITS[otherId].group === 'temperature') {
return otherId === 'celsius' ? 'fahrenheit' : 'celsius';
}
throw new Error('"度"可能指摄氏度或华氏度,请说明是哪一种');
}
if (hit) return hit;
throw new Error('不认识"' + String(raw).trim() + '"这个单位,支持:' + UNIT_LABELS);
}

这三条行为app.json的提示词里都写了,宿主和页面在这一点上是说好了的。
7. 口语解析
真实说话没人念参数,都是"帮我看看3英里是多远"这种,楼主给解析定了三步。第一步剥前缀:开场词清单从"请问""帮我换算"到"我想知道""麻烦"一共16个,放循环里反复试,"请帮我换算"这种叠罗汉也能一层层剥干净。第二步从中间切开:先找数值,阿拉伯数字直接用,中文数字走"零一二两三四五六七八九十百"的位值换算,"两"按2算;数值后面找一个连接词把原话劈成两半,连接词从"等于多少""是多少"这种长词一路排到"是""合""=",长的排前面。第三步两头清理:左边剥"的距离""的长度"这类缀词,右边先剥"吧呢吗啊呀"语气词,再循环剥"多少""多远"这类问词。"3英里是多远"走完三步就是源单位英里、值3、默认目标公里,哪一步卡住都有明确报错。三步全串在parseText一个函数里,原文抄在这:
javascript
export function parseText(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('没有收到要换算的原话');
let source = text.trim().normalize('NFKC').replace(/[??。!!~~\s]+$/, '').replace(/[吧呢吗啊呀]+$/, '').trim();
source = stripPrefixes(source).replace(/[??。!!~~\s]+$/, '').trim();
if (!source) throw new Error('没有听清要换算的内容,请说"100华氏度是多少摄氏度"这样的问法');
const match = source.match(NUMBER_RE);
if (!match) throw new Error('没有听清数值,请说"100华氏度是多少摄氏度"这样的问法');
const value = match[1] != null ? Number(match[1]) : cnToNumber(match[2]);
if (!Number.isFinite(value)) throw new Error('"' + match[0] + '"不像一个能换算的数值');
const after = source.slice(match.index + match[0].length);
const connector = after.match(CONNECTOR_RE);
const fromRaw = cleanLeft(connector ? after.slice(0, connector.index) : after);
const toRaw = cleanRight(connector ? after.slice(connector.index + connector[0].length) : '');
if (!fromRaw) throw new Error('没有识别出原始单位,请说"100华氏度是多少摄氏度"这样的问法');
return {value, fromRaw, toRaw};
}
参数进来有优先级:宿主的value和fromUnit齐了走结构化路径;缺了看原话,有text就解析;都没有回落第一组示例。结构化参数残缺不会立刻报错------原话里还有内容就继续走解析,原话也没有才报错:给了数值没给单位、只给了单位没给数值、两边同一个单位,各有各的提示。每个出口都有话说。
8. 反向换算
反向换算有个小讲究:回换用的是内部全精度值,不是屏幕上显示的四位有效数字。以100华氏度为例,内部算出37.7778循环小数,反向换算用那个没取整的值,换回去正好是100,楼主实测分毫不差;偷懒用显示值37.78回乘,连换几个来回误差就会渗进来。显示归显示,计算归计算,中间这道门不能开。
格式化统一取四位有效数字:3英里是4.828,5公斤是11.02,1英亩是4047;绝对值小于0.01且不为零的,改取六位小数再剥尾零。函数全文就六行:
javascript
export function formatNumber(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '---';
const rounded = Number(num.toPrecision(4));
if (rounded !== 0 && Math.abs(rounded) < 0.01) return rounded.toFixed(6).replace(/0+$/, '').replace(/.$/, '');
return String(rounded);
}
9. 语音输入
「语音说」按钮点下去,状态行先显示"正在连接语音...",连上后变"正在听,说完单击结束",识别的字实时回显,只留最后28个字符,说完再点一下结束,或20秒不说话自动收尾。收音模块挂三个兜底:启动10秒连不上报"语音未就绪";空闲20秒自动结束;手动结束后再等1.8秒让最终结果落回来,防有些引擎停得慢。麦克风权限被拒单独提示。一个细节和"随口算"不一样:那边报错让用户再点一次按钮,这边失败提示是"可用按键操作",因为这个页面光靠按键就能完成全部功能,语音挂了用户不必干等。页面被盖住时立刻停麦,每次开收音发运行序号,旧识别器晚到的回调自动作废。
10. 验证情况
楼主在AIUI Studio平台进行了如下的验证:
- 结构化路径:100华氏度换37.78摄氏度,进页面到出结果没有一步是页面编的
- 原话解析:"3英里是多远"得4.828;"2加仑等于多少升"得7.571;"1英亩是多少平方米"得4047
- 常用组抽测:5公斤得11.02磅;100英镑得918人民币,状态行补"参考汇率,非实时";10米得32.81英尺;12杯得2839毫升
- 歧义与拒绝:"100度是多少摄氏度"自动按华氏度处理得37.78;"40度等于多少度"追问;"光年"和"斤"被拒并附支持清单
- 往返一致性:100华氏度反向再反向,显示值分毫不差还是100;空参数回落第一组示例

在不断调试和迭代过程中终于将智能体上架,在这期间也是借用了平台的AI辅助开发的能力帮我进行了代码调优,AI辅助编程的便捷性拉满了,完全不用懂代码逻辑就能做出属于自己的智能体,当然楼主还是懂一点编程基础的~
11. 结语
楼主做这个智能体最大的感受是:日常换算的难点从来不在计算,在边界的划分。哪些单位认、哪些不认、拿不准怎么办,这些都要提前有个清楚的划分和规范,只有理清了这些才能开始写代码,剩下的就是一张表一个页面的事。眼镜上一句"3英里是多远",本地一毫秒出4.828,这种感觉比什么都实在。
欢迎各位体验我的智能体,要是觉得还缺哪个高频单位,回帖告诉楼主,下一版加进去。