ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent请求响应设计:鸿蒙+仓颉框架的工程实践

Agent请求响应设计:鸿蒙+仓颉框架的工程实践 1. 先从请求响应说起Agent 的“神经系统”到底怎么搭如果你前两篇已经搭过 CangjieMagic 框架的前置结构这篇我们直接聊最核心的部分——Agent 的请求和响应。为什么单独把“请求和响应”拎出来写一篇因为我在实际开发中栽过大跟头。最初做 Agent 的时候满脑子都是“Agent 怎么自我规划”“Agent 怎么调用技能”结果把请求响应层写得特别潦草一个MapString, String传参一个 JSON 字符串返回前面跑得挺欢一旦接入了多个模型、多个技能、多个端侧场景立刻原地爆炸。日志没法追踪、错误没法归类、多轮对话的上下文根本拼不回来、鸿蒙端和仓颉服务端各说各话光是联调就能耗掉大半精力。后来我重构了整个设计核心思路很简单把 Agent 的一次交互当成一次标准化的 RPC 调用来看待。请求有请求的协议响应有响应的协议中间加一条清晰的生命周期管道。听起来不酷但能救命。这篇文章是 CangjieMagic 框架系列的第三篇主要解决这几个问题Agent 请求应该用什么结构组织才能适配多模型、多技能、多轮对话Agent 响应应该怎么设计才能在鸿蒙端拿到结构化结果而不是一坨裸 JSON请求和响应在仓颉 鸿蒙这一套组合下怎么串起来才不容易出幺蛾子实际联调中常见的翻车现场以及怎么排查。如果你在做鸿蒙上的 AI Agent 开发、想把自研 Agent 框架接入鸿蒙端或者说你只是被 AI Agent 开发的热潮吸引、但面对请求响应设计毫无头绪这篇都能给你一个可直接抄作业的底稿。我全程会拿 CangjieMagic 框架的实际代码片段作为例子不会只给理论。2. Agent 请求设计参数、方法和上下文一个都不能少2.1 请求层到底要承载哪些信息先说结论一个合格的 Agent 请求至少要包含四类信息。第一类是方法名 / 意图标识。Agent 收到一句话得先知道这句话想触发什么能力。CangjieMagic 里我用SkillName和IntentName两层共同定位SkillName指哪个技能模块比如weather_query、todo_manageIntentName指具体意图比如query_now、create_task。两层定位的好处是后续加技能不用改请求核心结构而且支持“技能级路由 意图级分发”的灵活组合。第二类是参数列表。参数分为两部分一部分是业务参数来自用户输入的槽位比如“今天上海的天气”里“上海”是城市槽位“今天”是日期槽位另一部分是系统参数比如设备 ID、用户 ID、时区。我在框架里把这两类分开存放业务参数跟着用户输入走系统参数从AgentContext里自动注入避免每次调用都要手工拼。第三类是会话上下文。多轮对话不是一句换一个世界模型和技能都需要历史信息。CangjieMagic 的请求体里有一个context字段承载sessionId、历史消息摘要、最近一轮用户的原始输入。这里有个经验很多人喜欢把所有历史消息全部塞进请求结果请求体越来越大、Token 消耗越来越多我后来改成“摘要 近三轮全量”策略效果好非常多。第四类是控制参数。比如超时时间、流式输出标记、温度参数、最大 Token 数。这块不能放在业务参数里而是单独放一个config字段不然业务代码动不动就要透传模型参数污染很严重。2.2 一段实用的仓颉请求模型参考仓颉的语法风格比较现代这里我直接用仓颉写了一个精简版的请求模型public open class AgentRequest { public let requestId: String public let skillName: String public let intentName: String public let bizParams: HashMapString, String public let sysParams: HashMapString, String public let context: SessionContext public let config: RequestConfig public init(requestId: String, skillName: String, intentName: String, bizParams: HashMapString, String, sysParams: HashMapString, String, context: SessionContext, config: RequestConfig) { this.requestId requestId this.skillName skillName this.intentName intentName this.bizParams bizParams this.sysParams sysParams this.context context this.config config } public func toJson(): String { // 这里用仓颉的 JSON 序列化能力把对象转成结构化字符串 return JsonUtils.encode(this) } }使用的时候业务侧只需要这样组装let req AgentRequest( requestId req-00001, skillName weather_query, intentName query_now, bizParams [city: 上海, date: 今天], sysParams [deviceId: deviceId, userId: userId], context sessionContext, config RequestConfig(timeoutMs: 5000, enableStream: false) )我第一次写的时候图省事直接传了一个大 Map结果技能侧取参数全靠字符串 key写错了也无从查起。现在这种结构化写法每个字段都有编译期类型约束至少把“参数名拼错”这一类低级错误挡在编译之前。2.3 请求 ID 的生成别觉得很简单请求 ID 这件事看着不起眼实战里坑非常多。CangjieMagic 的请求 ID 格式是{时间戳}-{设备ID后四位}-{随机数}例如1715230012345-3f2a-8871。为什么这么设计第一排障的时候根据时间戳能快速定位到某段时间的请求第二带设备 ID 后四位能快速判断是哪个鸿蒙端设备发起的请求第三随机数保证并发下不重复。我吃过一次大亏系统上线后偶尔出现“日志里同一个请求 ID 出现两次”的情况排查半天发现是随机数生成器在多线程环境下用了一个不安全的种子导致极端并发下生成了重复 ID。后来我直接改用仓颉标准库里提供的 UUID 工具还特意做了一次重复率测试百万级生成量零重复。提示请求 ID 不是给人看的是给系统排查用的。宁可长得丑一点也要保证全局唯一、可追溯。3. Agent 响应设计状态码、载荷与结构化错误3.1 响应模型要解决什么痛点响应设计的核心目标是让调用方不看日志就能知道这次请求到底发生了什么。我在早期版本里犯过一个典型错误技能执行成功就返回一个 JSON 结果执行失败就返回一个带error字段的字符串。看起来没什么问题但接入第三方技能后就露馅了——有的技能返回空对象有的技能异常信息里包含换行符有的技能直接抛异常导致响应体连 JSON 格式都不是。前端拿到响应第一反应永远是if (resp null) { ... }根本不知道下一步该干什么。CangjieMagic 的响应模型由五部分组成requestId回显对应的请求 ID方便链路追踪code状态码成功 / 参数错误 / 技能不存在 / 模型调用失败 / 超时等message人在可读的提示信息bizData业务数据主体trace调试诊断信息包括技能执行耗时、模型调用步数3.2 状态码该怎么设计这里我用了一套分级状态码规则和 HTTP 状态码的思路类似但针对 Agent 场景做过裁剪状态码含义典型场景0成功技能执行成功bizData 中有完整数据100x请求级错误参数缺失、请求格式错误、会话不存在200x路由级错误技能不存在、意图不存在、技能未启用300x执行级错误技能内部异常、外部 API 调用失败400x模型级错误模型超时、模型返回格式非法、Token 超限500x框架级错误系统资源不足、配置错误把状态码分级而不是一个平铺的大列表最大的好处是调用方可以做粗粒度判断 细粒度处理。客户端只关心首位数服务端关心后两位前后端不用每次联调都对着文档翻半天。3.3 仓颉响应模型 JSON 序列化演示下面这段是 CangjieMagic 里响应模型的残化版核心还是可序列化、可扩展、可读public open class AgentResponse { public let requestId: String public let code: Int64 public let message: String public let bizData: JsonValue? public let trace: TraceInfo? public init(requestId: String, code: Int64, message: String, bizData: JsonValue?, trace: TraceInfo?) { this.requestId requestId this.code code this.message message this.bizData bizData this.trace trace } public static func success(requestId: String, bizData: JsonValue) - AgentResponse { return AgentResponse(requestId, 0, ok, bizData, nil) } public static func failure(requestId: String, code: Int64, message: String) - AgentResponse { return AgentResponse(requestId, code, message, nil, nil) } public func toJson(): String { return JsonUtils.encode(this) } }业务技能侧返回值的时候我是这样用的let data JsonUtils.encode(UserInfo(name: 张三, level: 3)) return AgentResponse.success(requestId, bizData: data)如果技能侧抛了业务异常CangjieMagic 会在调度层统一捕获然后转化为带状态码的AgentResponse不会把原始异常直接抛给上层。这一点在鸿蒙端接入时尤其重要因为 ArkTS 侧的异常处理和仓颉侧的异常模型不完全一致统一包装一次能避免很多莫名其妙的跨语言异常崩溃。3.4 为什么响应里要带 trace 信息trace字段我强烈建议保留哪怕生产环境可以关掉开发调试阶段也一定要开。CangjieMagic 的 trace 信息包含技能匹配耗时、模型调用次数、每步延迟、Token 消耗数。有一次用户反馈“某个技能偶尔很慢”我用 trace 一查发现每次慢请求都对应着模型连续重试了多次说明模型侧的上下文不太稳定。如果没有 trace这个性能问题根本无从定位。注意trace 信息有可能包含敏感参数上线时记得做脱敏处理比如把城市参数打码、把用户 ID 截断。千万别直接把整个请求原文打进 trace。4. 请求和响应如何串起来路由、分发与生命周期4.1 一个请求进来框架内部发生了什么CangjieMagic 的请求处理链路我用一个流程来描述鸿蒙端通过AgentClient发起AgentRequest经过 JSON 序列化后传入框架入口框架入口先做协议解析把 JSON 还原成AgentRequest对象进入前置拦截器链比如鉴权、限流、参数合法性校验通过SkillRouter根据skillName定位具体的技能实例技能实例内部根据intentName分发到具体的处理方法处理方法返回一个AgentResponse经过后置拦截器链比如日志记录、耗时统计最终把AgentResponse序列化回传给调用方这个链路看起来常规但有一个点值得展开聊——拦截器链。4.2 拦截器设计别把业务逻辑和横切逻辑混一起CangjieMagic 的拦截器接口长这样public interface Interceptor { func intercept(request: AgentRequest, next: (AgentRequest) - AgentResponse): AgentResponse }核心思想就是责任链模式你可以在拦截器里做参数校验、日志打印、权限检查、流控而不需要把这段逻辑塞到技能方法里。举个例子我在框架里实现了一个SessionCheckInterceptor负责校验sessionId是否有效如果会话过期直接返回 1003 状态码不再继续向下调用。这个逻辑如果放每一个技能实现里至少得重复写十几次而且一旦逻辑变更改到崩溃。还有一点实战经验拦截器顺序很重要。我建议顺序是协议解析 → 鉴权 → 限流 → 参数校验 → 会话校验 → 业务分发。因为限流要放在参数校验之前否则一个合法但高频的请求就白白做了一次参数校验鉴权要放最前连会话都没有的请求不值得继续消耗资源。4.3 路由层的细节技能注册表与反射分发技能注册表在 CangjieMagic 里用一个HashMapString, Skill实现框架启动时扫描并注册所有技能let registry HashMapString, Skill() registry.put(weather_query, WeatherSkill()) registry.put(todo_manage, TodoSkill())分发时只需要let skill registry.get(request.skillName) if (skill nil) { return AgentResponse.failure(request.requestId, 2001, skill not found) } let resp skill.handle(request)这套“注册表 统一接口”的模式是我从插件化架构里借鉴过来的。每个技能只需继承Skill基类并实现handle方法业务扩展就变成了纯粹的“新增一个类 注册一行代码”完全不用动框架层。对团队协作来说这就是隔离复杂度大家各改各的互不干扰。4.4 同步请求 vs 异步回调 vs 流式输出Agent 场景下请求响应模式绝对不能只有一种。我一开始只做了同步请求结果遇到一个搜索型技能执行一次要等模型推理好几秒鸿蒙端界面直接卡死。CangjieMagic 目前支持三种模式同步模式适用于耗时短、需要立刻拿到结果的场景比如简单问答、查单个字段异步回调模式适用于耗时中等、不想阻塞调用方的场景框架在技能执行完后调用回调函数流式输出模式适用于长文本生成场景模型先给一段框架立刻推送给客户端提升用户感知速度流式输出实现上有个注意点鸿蒙端如果直接通过 IPC 回调频繁的小包传输性能很差。我的做法是在框架内部做一个 200ms 的批量窗口攒一批内容再推送一次实测性能和延迟体验都好了不少。5. 进程内通信与跨端通信鸿蒙侧接入的正确姿势5.1 仓颉 Agent 框架和鸿蒙 UI 怎么桥接CangjieMagic 最早的版本其实是纯服务端设计跑在鸿蒙设备的后台进程里和 UI 完全隔离。但在实际项目中很多技能需要读取鸿蒙端的系统能力比如获取设备位置、读取通讯录、调用震动马达所以框架必须和鸿蒙 UI 层建立通信。我踩过的坑是直接把AgentClient做成一个单例对象里面持有Context结果在鸿蒙的 Ability 生命周期切换时这个单例经常持有旧的Context导致内存泄漏。后来我调整为两种方式结合如果 Agent 框架和 UI 在同一个进程直接用事件总线我封装了一套基于CommonEventManager的轻量总线做消息传递如果 Agent 框架在独立进程就通过鸿蒙的Ability 跨进程通信用MessageParcel传序列化后的 JSON 字符串两种方式都指向同一个入口AgentClient.send(request): PromiseAgentResponse。5.2 序列化与反序列化的坑仓颉和 ArkTS 的数据类型差异这是跨端开发里最消耗耐心的环节。仓颉侧的HashMapString, String序列化成 JSON到 ArkTS 侧解析后变成Recordstring, string看起来没啥问题实际遇到 null 值就完蛋——ArkTS 的严格模式不接受Recordstring, string里出现 null而仓颉侧序列化空值时经常会输出 null。我的解决方法是所有可选字段序列化时统一跳过空值或者在 ArkTS 侧解析时统一做宽容处理。另外要特别注意数字类型。仓颉的Int64在序列化成 JSON 后是一个数字但 ArkTS 侧如果用JSON.parse解析大整数可能会丢失精度。我在请求 ID 设计时就避开了纯数字改用字符串格式这个决策在后期帮了大忙。5.3 一个完整的最小链路演示我把鸿蒙端发起一次 Agent 请求的最小链路写在这里方便照着接入// ArkTS 侧 import { AgentClient } from cangjiemagic/core let req { requestId: req-1715230012345-3f2a-8871, skillName: todo_manage, intentName: create_task, bizParams: { title: 买牛奶, deadline: 今天 18:00 }, sysParams: { deviceId: ABC123 }, context: { sessionId: session-001, history: [] }, config: { timeoutMs: 5000, enableStream: false } } let resp await AgentClient.send(JSON.stringify(req)) let code resp.code if (code 0) { // 解析 bizData console.log(Task created: resp.bizData) } else { console.error(Agent error: resp.message) }这段代码是真实项目中我见过最典型的调用写法。重点在code的判断上而不是用 try-catch 包住整个调用。Agent 的“业务失败”和“系统异常”一定要分开对待。业务失败比如参数不合法是预期内的走状态码分支处理系统异常比如网络断了才应该用异常捕获。5.4 端侧模型调用模型接口的统一封装CangjieMagic 框架内部对不同模型提供商的接口做了一层适配。不管是云端的模型接口、还是鸿蒙端侧部署的轻量模型统一封装成ModelProvider接口public interface ModelProvider { func chat(messages: ArrayChatMessage, config: RequestConfig): ModelResult }这样上层技能完全不用关心底层到底接的是哪个模型只需要面向接口编程。我在项目中实际切换过一次模型服务商只改了注册模块的配置技能代码一行没动。这种松耦合设计对 Agent 框架特别重要因为模型领域迭代太快今天用的模型明天可能就被更好的替代架构上必须支持低成本切换。实操心得如果你的 Agent 框架正在起步阶段不要一上来就铺太多模型厂商先稳定接一家把协议定好。协议稳定了后面接第二家、第三家就只是工作量问题。6. 常见问题与排查技巧实录6.1 问题速查表我在 CangjieMagic 的开发和联调过程中整理了一张高频问题排查表分享出来现象可能原因排查方法请求发出后无任何响应技能注册失败检查技能注册表是否有对应 skillName响应里 bizData 是 null技能方法抛异常被统一捕获查看 trace 中是否有异常堆栈鸿蒙端解析响应报错字段类型不匹配抓包对比 JSON 原始结构和 ArkTS 解析类型多轮对话上下文丢失会话存储未持久化检查 SessionContext 是否有写入存储的逻辑流式输出卡顿小包频繁传输调整批量窗口为 200ms ~ 500ms部分请求耗时异常高模型多次重试查看 trace 中的模型调用次数和延迟6.2 排查技巧日志链路追踪怎么做请求响应联调过程中最怕的是“前端说发了我不知道后端说没收到我不知道”。CangjieMagic 从请求进入框架的第一步就打印一条统一的日志[AgentRequest] requestIdreq-xxx skillweather_query intentquery_now begin [AgentRequest] requestIdreq-xxx skillweather_query intentquery_now end cost230ms code0前后两条日志对齐就能看到某个请求在框架内部每个环节的耗时。如果只打了 begin 没打 end就能确定是技能执行阶段出了问题直接去查该技能日志即可。还有一个技巧是给每个拦截器单独加耗时埋点。定位性能瓶颈时能精确到是哪个拦截器、哪个技能方法消耗了大头不用靠猜。6.3 一个典型的联调翻车现场有次前端同事反馈鸿蒙端调用 Agent 创建待办结果 UI 上提示成功但半天后打开待办列表根本找不到这条数据。第一反应是写入 MySQL 失败。查了日志发现AgentResponse.code 0技能确实执行成功了但技能内部只是调用了内存态待办服务进程重启后数据就丢了。也就是说业务确认成功 ≠ 数据持久化成功。这个问题的根因是“待办服务”还没有持久化实现但技能层已经提前返回了成功响应。后来我在 CangjieMagic 的技能开发规范里强制加了一条技能返回成功响应之前必须确认所有关键副作用已完成。这个坑太典型了尤其在 AI Agent 场景下用户对“AI 说做了”但“实际没做到”的容忍度是非常低的。6.4 性能优化响应体瘦身与批量预测CangjieMagic 在多技能并行请求时会遇到响应体过大导致鸿蒙端解析慢的问题。我做了一次全链路响应体瘦身从单次平均 8KB 降到 2.5KB主要手段包括去掉不需要的 trace 字段、压缩模型返回的中间推理文本、对列表类型数据只返回到前端展示所需字段。这里分享一个真实数据在我们内部的鸿蒙真机测试中响应体从 8KB 降到 2.5KB 后端到端完成时间从 850ms 降到 620ms提升了约 27%。在弱网环境、跨设备场景下这个提升还会更明显。所以响应体瘦身是性价比极高的一项优化不要只盯着模型推理时间。7. 关于这套方案我再补充几句实战体会折腾完 CangjieMagic 的 Agent 请求和响应层我最深的体会是Agent 框架成败往往不是看模型选得多聪明、Prompt 写得多花哨而是看请求响应这条链路稳不稳定。模型可以换、Prompt 可以调但请求响应协议一旦定了后面所有技能开发、端侧接入、问题排查都建立在它之上。协议设计不好后面每个迭代都在付利息。如果你也正在做类似的项目我建议你从第一天就坚持几件事所有 Agent 请求必须有唯一 ID所有响应必须有状态码所有关键路径必须有日志所有模型调用必须有超时。这四条听起来就算是工程常识但在 AI Agent 这个新领域里我发现太多项目连最基本的请求追踪都没有。最后再分享一个小技巧给你的请求响应模型加一个版本号字段。Agent 框架和技能是独立演进的技能可能升级模型可能换接口但是请求响应模型未必能做到完全向后兼容。加一个version字段未来做协议升级时可以按版本号做兼容分发而不是一把梭改完直接线上爆炸。这个字段现在只占几个字节将来能帮你省下的时间是几个通宵。
返回列表