“帮我查一下今天是白露还是秋分,适合吃什么?”——这个需求被丢到我面前时,我第一反应是:直接写个查表函数不就完了?但放在鸿蒙智能体里,用户的问法千奇百怪,问题里可能带前后语境,返回内容还要能被页面卡片直接消费。于是就有了这篇鸿蒙智能体实战记录04:节气知识查询工作流。整个方案只有一个节点,核心就两个词:结构化输出、字段完整性控制。
做智能体时间久了你会发现,模型对话能力越强,越容易在“输出可控性”上翻车。用户问节气,模型能答得绘声绘色,下游却拿不到一个包含节气名、日期、习俗、养生建议、推荐食材的固定结构,页面渲染只能整段整段展示文本,体验很差。这篇记录不聊花哨的多Agent编排,只讲怎么用单节点把节气知识查询做稳,把结构化输出和字段完整性控制这两件事一次做扎实。适合正在学鸿蒙智能体开发、又被模型输出稳定性折磨的同学,也适合任何想从“能对话”走向“能交付”的智能体项目参考。
1. 项目背景与整体设计思路
1.1 为什么拿节气知识当练兵场
选节气知识做智能体场景,不是因为需求方只要一个节气问答,而是因为它天然适合验证智能体能力。24个节气是固定集合,数据可以离线整理;每个节气对应的日期、习俗、饮食建议都是明确的,几乎没有开放性解释空间;用户提问却高度口语化,“今天什么节气”“白露吃啥”“霜降要干嘛”都能落到同一套字段上。
这种“数据固定、查询随性”的组合,正好能暴露出智能体最常见的三类问题:一是模型把节气日期记错,靠记忆生成数据;二是输出格式漂移,今天给你JSON,明天给你散文;三是字段缺胳膊少腿,说好的推荐食材漏了,养生建议也不见了。把这三类问题放在一个轻量场景里解决,比一上来就做复杂业务Agent划算得多。
还有一个考虑是验收简单。24个节气每个跑一遍,结果对不对一眼就能看出来。对比那种开放式问答,节气查询有明确答案,最适合做结构化输出的“最小可用样板”,后续换到商品推荐、文章摘要、任务拆解,思路可以直接平移。
1.2 单节点工作流:为什么不拆多节点
市面上很多智能体教程喜欢堆节点:先意图识别,再知识检索,再格式化输出,再兜底处理。这套东西大项目确实需要,但节气查询这种轻场景,拆多了反而亏。我最后只用了一个节点,用户输入进来,模型一次性完成“理解问题+匹配节气+生成结构化JSON”的完整动作。
单节点的好处非常实在。第一是少一次节点就少一轮网络交互,整体耗时降下来,对话体感更跟手。第二是调试成本低,日志里一个请求对应一个响应,输出要是坏了,原因就集中在提示词和模型参数上,不会出现“不知道哪一步把上下文带偏”的情况。第三是数据流简单,不需要在多个节点间维护一套复杂的中间数据结构。
那什么时候该拆多节点?答案也很明确:当任务需要外部查询、需要分阶段确认边界、或者单节点提示词撑不住超长指令时再拆。节气知识虽然涉及数据,但数据规模小到可以预先塞进提示词上下文里,单节点完全够用。我在提示词里把规则前置、把示例写足,其实就是在单节点内部做了一个微型约束引擎。
1.3 结构化输出要解决的核心问题
结构化输出说起来高大上,本质就一件事:让模型返回一段可解析、可校验、可直接渲染的数据,而不是一段漂亮的人类语言。节气查询工作流里,下游页面需要的是一个包含name、date、customs、health、foods、taboos等字段的对象,这样卡片才能把习俗列表、推荐食材、禁忌事项分别展示,而不是把所有信息揉进一个长段落。
但光有JSON还不够,真正难的是“字段完整性控制”。模型输出很随机,今天记得返回7个字段,明天可能只给5个;今天数组里有值,明天直接给null;今天日期是字符串,明天变成“9月7日”。字段完整性控制不是要求模型百分之百稳定,这不现实,而是要在系统层面保证:字段缺失时能检测到,类型错误时能纠正,没有答案时能兜底。这样下游拿到的一定是符合契约的数据,空值也不会导致渲染崩溃。
这里有个容易被忽略的隐性价值:一旦输出结构固定,整个智能体就可以进入自动化测试流程。我后面写了一套批量脚本,把24个节气、几十种问法全部灌进去跑回归,哪一次输出坏了,一眼就能发现。如果没有结构化输出,回归测试只能靠人肉读对话记录,效率低到没法看。
2. 字段模型与完整性控制策略
2.1 先定字段模型,再写提示词
做这个工作流时我踩过最大的坑,就是先写提示词后定义字段。提示词里说“请返回习俗和养生建议”,模型自由发挥的空间太大,导致下游解析代码越写越复杂。后来我把顺序彻底倒过来:第一件事,画字段模型表。
节气查询工作流最终定义的结构长这样:
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| name | string | 必填 | 节气名,如“白露” |
| date | string | 必填 | 公历日期,固定格式YYYY-MM-DD |
| solar | number | 必填 | 太阳到达黄经度数,如165 |
| customs | string[] | 选填 | 传统习俗列表,没有则返回空数组 |
| health | string | 选填 | 养生建议,一句话描述 |
| foods | string[] | 选填 | 推荐食材列表,没有则返回空数组 |
| taboos | string[] | 选填 | 禁忌或注意事项列表 |
| source | string | 必填 | 数据来源说明,避免模型胡编 |
字段设计有几个细节值得展开。date必须用字符串而不是时间对象,因为节气日期依年份变化,JSON序列化时字符串最好处理,解析层不需要依赖时区配置。数组字段默认值必须是空数组,不能用null,否则下游渲染时做map操作会直接报错。source字段虽然看起来业务价值不大,但它是“信息可信度”的锚点,逼着模型在不知道答案时承认不知道,而不是随手编一个来源。
2.2 面向模型写约束,而不是面向人写文案
字段表定好之后,提示词才能真正写得“硬”。这里有一个核心认知:系统提示词是写给模型看的代码,不是写给用户看的文案。很多开发者写提示词喜欢用“请尽量”“希望你能”这类礼貌用语,模型确实会照做,但“尽量”就意味着有商量余地,输出漂移就是从这里来的。
我给节气知识查询工作流设计的系统提示词长这样:
你是节气知识助手。用户提问可能涉及节气日期、习俗、饮食建议等。 无论用户说什么,你必须按如下JSON结构返回,不要输出任何额外解释、前后缀或Markdown标记。 {"name":"节气名","date":"YYYY-MM-DD","solar":太阳黄经度数,"customs":["习俗1","习俗2"],"health":"一句养生建议","foods":["食材1","食材2"],"taboos":["禁忌1","禁忌2"],"source":"数据来源"} 硬性规则: 1. 以上9个字段(键名)必须全部出现,禁止省略。 2. 数组字段没有信息时返回空数组[],不要返回null。 3. 字符串字段没有信息时返回空字符串"",不要编造。 4. 不知道节气日期或来源时,date填"1970-01-01",source填"未知",同时不得伪造其他解释。 5. 只输出JSON本身,不要输出“好的”“这是结果”之类的话。 6. 如果用户询问的不是节气相关内容,name统一填“无法识别”,其余字段按规则置空。这份提示词看起来有点“凶”,但实测下来效果很稳。我特别想把第4条展开讲:给模型一个“不知道时的标准答案”,比单纯说“不知道不要乱说”有效得多。因为模型在训练时被强化了“必须给出有意义回答”的倾向,你只说不许编,它反而会硬编;你给它一个“1970-01-01”和“未知”这样的安全出口,它知道承认不知道才是合规路径。
2.3 解析、校验、兜底三层防线
提示词写得再硬,也不能保证模型输出每一句话都严格合规。没有任何提示词工程能做到这一点,所以系统侧必须有完整的防御链。我的做法是三层防线:解析层负责把文本变成对象,校验层负责检查字段合法性,兜底层负责把非法值替换成安全默认值。
解析层要处理的坑主要是模型输出里夹带非JSON内容。即使提示词说了“不要输出任何额外解释”,模型偶尔还是会在JSON外面包一层Markdown代码块标记,或者开头加个“好的”。我的处理方法是先做一轮文本清洗:找到文本中第一个“{”和最后一个“}”,截取中间内容,再交给JSON解析器。这一步非常土,但对付模型输出的稳定性异常有效。
校验层的核心是类型检查。节气名必须是字符串,数组必须是数组,数字必须是有限数值。我会用一个校验函数逐个字段检查,一旦发现类型不对,立刻交给兜底层处理,而不是直接抛异常中断整个工作流。兜底策略上我配置了中性默认值:name缺失就返回“未知节气”,date缺失就返回“1970-01-01”,customs、foods、taboos缺失就返回空数组,health缺失就返回“暂无数据”。这样下游拿到的永远是完整结构,只是信息量为空,UI层面可以优雅降级。
3. 实操过程与核心代码
3.1 准备节气数据源:不要指望模型记日期
节气日期是天文历法问题,模型记错是常态。我一开始偷懒,想让模型直接从知识库生成日期,结果白露的日期差了整整一天。后来老老实实整理了一份节气数据表,放在本地作为可信数据源。
数据源结构做成常量文件,以节气名为key,存下日期、黄经度数、习俗、食材、禁忌、养生建议等。下面给出一段简化示例:
export const SOLAR_TERMS: Record<string, SolarTermResult> = { "白露": { name: "白露", date: "2024-09-07", solar: 165, customs: ["收清露", "饮白露茶", "祭禹王"], health: "昼夜温差加大,注意腹部保暖,预防秋燥", foods: ["梨", "银耳", "山药", "百合"], taboos: ["少食辛辣", "避免生冷", "不宜过度进补"], source: "本地权威历法数据" }, "霜降": { name: "霜降", date: "2024-10-23", solar: 210, customs: ["赏菊", "吃柿子", "登高远眺"], health: "天气渐冷,注意关节保暖,饮食宜平补", foods: ["柿子", "栗子", "萝卜", "秋梨"], taboos: ["少食寒凉", "避免运动过量"], source: "本地权威历法数据" } // 其余节气,建议按年份扩展近五年数据 };关于这份数据,我有一条硬经验:年份不同,节气日期会前后浮动一到两天。所以不要只存一份“标准日期”,要么做成按年份索引的多版本数据表,要么在构建时动态获取当年的历法数据。我在实际项目里预置了近五年的数据,因为节气表数据量不大,全部加载进内存也就几十KB,对智能体整体性能毫无影响。
3.2 模型调用参数与提示词注入方式
数据源准备好之后,真正的工作流节点开始搭建。模型调用参数我固定为温度0,关闭随机性。节气查询是事实型任务,不需要模型展现创造力,温度再低都不为过。
调用时将系统提示词、用户输入、少量示例一起发送。下面这段示例既演示格式,又告诉模型“用户问法可以很随意,但你输出必须严格”:
用户:今天白露,能吃什么水果? 输出:{"name":"白露","date":"2024-09-07","solar":165,"customs":["收清露","饮白露茶"],"health":"昼夜温差加大,注意腹部保暖","foods":["梨","银耳","山药"],"taboos":["少食辛辣"],"source":"本地权威历法数据"} 用户:霜降有什么民俗? 输出:{"name":"霜降","date":"2024-10-23","solar":210,"customs":["赏菊","吃柿子","登高远眺"],"health":"天气渐冷,注意关节保暖","foods":["柿子","栗子","萝卜"],"taboos":["少食寒凉"],"source":"本地权威历法数据"}示例不需要多,两条就够。一条覆盖“饮食习惯类提问”,一条覆盖“民俗类提问”,模型就能抓到“无论用户怎么问,我只回JSON”的规律。这里有个实操细节:示例中的日期最好和当前年份一致,否则模型可能为了对齐示例年份而把日期带回旧年份。
3.3 后处理解析、校验与兜底实现
模型返回之后,所有魔法都在后处理函数里。我先定义一个接口,保证下游拿到的永远是规范对象:
interface SolarTermResult { name: string; date: string; solar: number; customs: string[]; health: string; foods: string[]; taboos: string[]; source: string; }然后是清洗和解析函数。核心思路是“先截取JSON,再解析,再兜底”,把异常拦截在最外层:
function parseModelOutput(text: string): SolarTermResult { const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start === -1 || end === -1) { throw new Error("模型输出中未找到JSON对象"); } const jsonText = text.slice(start, end + 1); const raw = JSON.parse(jsonText); return fillDefaults(raw); }这里的fillDefaults就是字段完整性控制的落脚点。它不追求“模型一次输出完美”,而是确保“哪怕模型输出残缺,系统返回也一样完整”:
function fillDefaults(raw: any): SolarTermResult { const str = (v: any): string => (typeof v === "string" ? v : ""); const arr = (v: any): string[] => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []); const result: SolarTermResult = { name: str(raw.name) || "未知节气", date: str(raw.date) || "1970-01-01", solar: typeof raw.solar === "number" && isFinite(raw.solar) ? raw.solar : 0, customs: arr(raw.customs), health: str(raw.health) || "暂无数据", foods: arr(raw.foods), taboos: arr(raw.taboos), source: str(raw.source) || "未知", }; return result; }这个实现有几个细节我想专门强调。数组字段我在兜底时不仅检查了是否为数组,还过滤了数组中非字符串元素,防止模型输出混入数字或对象导致下游渲染报错。solar字段的值合法范围是0到360,仅仅检查isFinite还不够稳,建议加一个区间判断。date字段的兜底值是“1970-01-01”,这个值看起来奇怪,但它的作用是让UI层知道“日期缺失”,而不是真的让用户以为节气在1970年,所以还需要配合日志把原始异常记录下来。
3.4 把工作流节点串成完整链路
解析校验跑通后,把整个工作流节点串起来就顺畅了。完整链路的执行顺序是:接收用户输入,做文本清洗(去掉多余空格和换行),把系统提示词和输入一起交给模型,拿到模型原始返回,执行上述清洗解析逻辑,最后返回标准化对象给上层。
我还加了一个“强制单次重试”的机制。当第一次解析失败时,不直接返回默认值,而是把错误提示拼进系统提示词,让模型重新生成一次。重试提示词大致是“你上一次输出不是合法JSON,请重新输出,只输出JSON本身,不要附带任何解释”。这样做的好处是,绝大多数“模型开小差”的情况都能通过一次重试修复,避免了用户页面直接看到兜底数据。
整条链路调试时,我会把模型原始输出和解析后的结果同时打到日志里。对比着看,能快速判断问题是出在模型没听懂,还是清洗逻辑没覆盖到新的异常格式。我建议每一位做智能体项目的人都保留这一步,别为了省事只记最终结果,那样排查问题会像盲人摸象。
4. 常见问题与排查经验实录
4.1 节气日期不准:模型记忆不等于日历事实
做这个工作流的时候,我第一批测试就翻车了。问“白露是哪天”,模型回答的日期和权威历法差了48小时。问题根源在于模型训练数据里包含了多年份的节气日期,它可能把去年的日期当成了今年,也可能把“白露在9月上旬”这个模糊记忆直接具体到某一天。
解决思路是彻底不让模型负责日期记忆。我在系统提示词里增加一条:节气日期请参考提供的本地数据,不要根据记忆推算。同时在本地数据表里把当前年份的节气日期标红置顶,这样模型即使想编造,也会优先参考上下文中的权威信息。如果业务上允许,更保险的做法是先做节气名称识别,再从本地表里直接取日期,完全不依赖模型输出日期。
这类问题最容易被忽略,因为模型答错的日期往往只差一两天,人工review时很难一眼看出。后来我写了一个校验脚本,把模型返回的date字段和本地数据表逐条比对,只要不一致就标记为错误,才算彻底堵住这个坑。
4.2 模型输出不是合法JSON:清洗、重试、降级
“模型开始说人话了”是结构化输出最常见的翻车现场。明明提示词写了只输出JSON,模型却在JSON前面加了“好的”,后面加了“祝你健康”,有时候还把JSON包在一个代码块标记里。遇到这种输出,直接JSON.parse必挂。
我现在的处理顺序是:先用正则把首尾的非JSON内容剥掉,再把常见的“json”“”标记替换为空字符串,然后尝试解析。如果解析仍然失败,进入一次重试。重试仍失败,就用兜底对象降级返回,同时在日志里标记“模型输出异常,已降级”。这里最忌讳的是服务端直接抛异常,让整个智能体崩掉;对用户来说,拿到一个字段为空的卡片,比看到系统错误提示要温和得多。
我还发现一个规律:模型在输出尾部更容易“加戏”。所以清洗时,截取最外层花括号之间的内容这个动作务必保留,不要只做简单trim。用我前面给的start和end截取方法,基本能应对大多数不按规矩出牌的格式。
4.3 数组字段变成null,下游渲染崩了
数组字段的坑比字符串字段坑得多。字符串缺失顶多显示空白,数组缺失要让卡片调map方法,分分钟抛TypeError。我一开始没注意,结果霜降的taboos字段模型返回了null,页面直接白屏。
提示词里我加了“数组字段没有信息时返回空数组[],不要返回null”这条硬规则,同时在兜底函数里无差别把所有数组字段都转成空数组。就算模型疯了,这个字段也不可能再是null。这个双保险的思路对一切“下游强依赖结构”的字段都适用:前端要什么类型,服务端就必须保证什么类型,哪怕内容为空也不能给错类型。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 节气日期差一天到两天 | 模型记忆了往年日期、时区偏差 | 本地权威数据表优先,禁止模型推算日期 |
| 输出夹带“好的”等解释文字 | 提示词约束不够强 | 清洗首尾非JSON内容,截取花括号内容后解析 |
| JSON里出现尾逗号、注释 | 模型生成格式不规范 | 清洗层做替换或正则移除后再解析 |
| 数组字段返回null | 模型理解偏差 | 提示词强调空数组,兜底层强制转数组 |
| 字段数量不全 | 模型注意力被长指令稀释 | 必填字段前移,加few-shot示例 |
| 连续多次输出失败 | 模型上下文污染 | 做一次重试;再失败则降级,记日志 |
| 问非节气内容时硬答 | 缺少“无法识别”分支 | 提示词设定无关注入“无法识别”标准对象 |
4.5 调试工具与回归验证
等整个链路稳定以后,我强烈建议建一个本地回归脚本。把24个节气、每个节气至少5种问法、加上各种边界输入(比如“今天天气怎么样”“随便说说”)全部灌进工作流,批量检查输出结构是否符合预期。脚本只需要做三件事:断言JSON能解析、断言所有字段存在、断言数组字段类型正确。
这套脚本第一次跑出来让我非常意外,24个节气里竟然有3个节气的健康建议字段缺失,而人工对话测试中情绪兴奋时根本注意不到这种细节。回归脚本的价值就在于此:它用最笨最机械的方式,把模型输出的每一个字段都检查一遍,让“偶尔缺字段”这种最容易逃过肉眼的问题无所遁形。
5. 后续扩展与能力延伸
5.1 什么情况下要把单节点拆成多节点
文章前面强调单节点的好处,但工程上没有银弹。我需要把这个工作流拆成多节点的场景其实是有的:当数据源从本地常量变成远程API时,或者当需要根据用户画像定制回复时,单节点提示词会变得非常臃肿,这时候就该拆。
一种合理的拆法是三个节点:第一节点做“节气名称识别”,只负责从用户输入里抽出节气名,不负责生成知识内容;第二节点做“数据装配”,从本地表或API查出完整数据,拼成JSON;第三节点做“自然语言增强”,把JSON转成更友好的话术。这样的好处是职责清晰,坏处是链路变长、延迟增加。我目前保留单节点,因为节气查询的响应速度更重要,但代码里已经预留了扩展接口,数据装配函数可以直接换成服务端查询。
5.2 用缓存挡掉高频重复请求
节气知识的特点是答案不经常变。同一天里用户连续问三次“白露吃什么”,模型每次都重新生成一遍,既浪费算力,又增加了出错概率。我在工作流前面加了一个基于节气名和当前日期的简单缓存:如果用户输入能被快速解析为某个节气,直接从缓存返回,连模型都不用调。
缓存实现不复杂,一个Map就能搞定,key是节气名加日期,value是之前生成的结构化结果。缓存的唯一麻烦是需要处理“过期”问题——节气日期跨年后要清掉旧数据。好在这个业务轻量,我直接定时每天凌晨清空一次缓存,简单粗暴,但完全够用。对性能敏感的项目,这种“重查询、稀变更”的场景非常适合用短缓存扛压力。
5.3 回归测试与长期维护要点
项目上线后,长期维护的核心就是保证数据更新不落空。节气表每年都要更新,如果只更新数据源而忘了更新缓存,用户就会在第二年拿到去年的日期。我维护了一组测试用例,专门覆盖“跨年”场景,强制要求不同年份的节气日期不能相同,一旦重复就报错。
另外一点,模型版本升级会带来输出行为变化。好不容易调好的提示词,模型一升级可能又回到“带解释输出”的坏习惯。应对办法是把回归脚本接到发布流程里,每次模型服务版本变更都重跑一遍节气测试集,看结构化输出通过率是否下降。我吃过这个亏,那次模型升级后节气输出通过率直接从97%掉到71%,排查了一个下午才发现是版本变化导致,从那以后回归脚本就成了标配。
如果你打算在鸿蒙智能体里做类似的知识查询场景,我的建议很简单:先把字段模型画出来,再写提示词,最后写兜底函数,顺序千万不要反。我最开始就是先写提示词后补字段,结果来回返工。这套单节点加字段完整性控制的思路,已经在团队内部被复用到了好几个项目上。希望这篇记录能帮你少走一点弯路。