☰
Agent请求响应设计:鸿蒙+仓颉框架的工程实践
2026/10/3 5:31:26 网站建设 项目流程

1. 先从请求响应说起:Agent 的“神经系统”到底怎么搭

如果你前两篇已经搭过 CangjieMagic 框架的前置结构,这篇我们直接聊最核心的部分——Agent 的请求和响应。

为什么单独把“请求和响应”拎出来写一篇?因为我在实际开发中栽过大跟头。最初做 Agent 的时候,满脑子都是“Agent 怎么自我规划”“Agent 怎么调用技能”,结果把请求响应层写得特别潦草:一个Map<String, 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_manage),IntentName指具体意图(比如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: HashMap<String, String> public let sysParams: HashMap<String, String> public let context: SessionContext public let config: RequestConfig public init(requestId: String, skillName: String, intentName: String, bizParams: HashMap<String, String>, sysParams: HashMap<String, 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 的请求处理链路我用一个流程来描述:

  1. 鸿蒙端通过AgentClient发起AgentRequest,经过 JSON 序列化后传入框架入口
  2. 框架入口先做协议解析,把 JSON 还原成AgentRequest对象
  3. 进入前置拦截器链,比如鉴权、限流、参数合法性校验
  4. 通过SkillRouter根据skillName定位具体的技能实例
  5. 技能实例内部根据intentName分发到具体的处理方法
  6. 处理方法返回一个AgentResponse
  7. 经过后置拦截器链,比如日志记录、耗时统计
  8. 最终把AgentResponse序列化回传给调用方

这个链路看起来常规,但有一个点值得展开聊——拦截器链。

4.2 拦截器设计:别把业务逻辑和横切逻辑混一起

CangjieMagic 的拦截器接口长这样:

public interface Interceptor { func intercept(request: AgentRequest, next: (AgentRequest) -> AgentResponse): AgentResponse }

核心思想就是责任链模式,你可以在拦截器里做参数校验、日志打印、权限检查、流控,而不需要把这段逻辑塞到技能方法里。

举个例子,我在框架里实现了一个SessionCheckInterceptor,负责校验sessionId是否有效,如果会话过期,直接返回 1003 状态码,不再继续向下调用。这个逻辑如果放每一个技能实现里,至少得重复写十几次,而且一旦逻辑变更,改到崩溃。

还有一点实战经验,拦截器顺序很重要。我建议顺序是:协议解析 → 鉴权 → 限流 → 参数校验 → 会话校验 → 业务分发。因为限流要放在参数校验之前,否则一个合法但高频的请求就白白做了一次参数校验;鉴权要放最前,连会话都没有的请求不值得继续消耗资源。

4.3 路由层的细节:技能注册表与反射分发

技能注册表在 CangjieMagic 里用一个HashMap<String, Skill>实现,框架启动时扫描并注册所有技能:

let registry = HashMap<String, 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): Promise<AgentResponse>。

5.2 序列化与反序列化的坑:仓颉和 ArkTS 的数据类型差异

这是跨端开发里最消耗耐心的环节。仓颉侧的HashMap<String, String>序列化成 JSON,到 ArkTS 侧解析后变成Record<string, string>,看起来没啥问题,实际遇到 null 值就完蛋——ArkTS 的严格模式不接受Record<string, 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: Array<ChatMessage>, config: RequestConfig): ModelResult }

这样上层技能完全不用关心底层到底接的是哪个模型,只需要面向接口编程。我在项目中实际切换过一次模型服务商,只改了注册模块的配置,技能代码一行没动。这种松耦合设计对 Agent 框架特别重要,因为模型领域迭代太快,今天用的模型明天可能就被更好的替代,架构上必须支持低成本切换。

实操心得:如果你的 Agent 框架正在起步阶段,不要一上来就铺太多模型厂商,先稳定接一家,把协议定好。协议稳定了,后面接第二家、第三家就只是工作量问题。

6. 常见问题与排查技巧实录

6.1 问题速查表

我在 CangjieMagic 的开发和联调过程中,整理了一张高频问题排查表,分享出来:

现象可能原因排查方法
请求发出后无任何响应技能注册失败检查技能注册表是否有对应 skillName
响应里 bizData 是 null技能方法抛异常被统一捕获查看 trace 中是否有异常堆栈
鸿蒙端解析响应报错字段类型不匹配抓包对比 JSON 原始结构和 ArkTS 解析类型
多轮对话上下文丢失会话存储未持久化检查 SessionContext 是否有写入存储的逻辑
流式输出卡顿小包频繁传输调整批量窗口为 200ms ~ 500ms
部分请求耗时异常高模型多次重试查看 trace 中的模型调用次数和延迟

6.2 排查技巧:日志链路追踪怎么做

请求响应联调过程中最怕的是“前端说发了我不知道,后端说没收到我不知道”。CangjieMagic 从请求进入框架的第一步就打印一条统一的日志:

[AgentRequest] requestId=req-xxx skill=weather_query intent=query_now begin [AgentRequest] requestId=req-xxx skill=weather_query intent=query_now end cost=230ms code=0

前后两条日志对齐,就能看到某个请求在框架内部每个环节的耗时。如果只打了 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字段,未来做协议升级时,可以按版本号做兼容分发,而不是一把梭改完直接线上爆炸。这个字段现在只占几个字节,将来能帮你省下的时间是几个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询