上篇把云端大模型接进了 HarmonyOS 7(API 26),底座能稳定返回一段文本。但真实业务里的 Agent 是多轮、有记忆、有界面的:用户问一句,Agent 结合上下文答一句;历史越长,上下文管理越要精细。不少团队的第一步,是把消息数组、系统提示词、网络调用全混进 Index.ets ——改一处崩三处,第二个人接手根本不敢动。
本文继续用可开箱即跑的 DevEco 工程,把这一层收敛成一套 Agent 工程脚手架 : ConversationStore 管对话记忆与上下文裁剪, Agent 管编排, ChatUIMessage 管界面状态映射,最后用一套聊天页骨架把三者串起来。底层复用上篇的 LlmClient (华为云盘古 / OpenAI 兼容),本篇代码聚焦"Agent 该怎么组织",纯逻辑全部配了可脱离设备运行的 Hypium 单测。
脚手架的价值在于拆:把易变的界面、稳定的编排、可替换的底座分成三层,每层只依赖下一层的抽象。
UI 层 :聊天页骨架 + ChatUIMessage 视图模型,只管把状态画出来。
Agent 编排层 : Agent 持有系统提示词和 ConversationStore ,一次 send() 完成"落用户消息 → 调底座 → 落助手回复"。
云端底座层 :复用上篇 LlmClient 抽象,超时、重试、解析已收口,本篇零改动直接复用。
这样拆完,换界面不影响编排,换模型厂商不影响记忆管理——三层各自独立演进,这就是脚手架该有的样子。
ConversationStore 是脚手架里最容易被写错的部分。两个关键点: 系统消息必须永远留在发给模型的消息序列里 (丢了它,Agent 的角色设定就失效); 多轮历史不能无限堆 (上下文窗口撑爆,费用也跟着涨)。
export
class
ConversationStore
{
private
messages
:
ChatMessage
[] = [];
private
systemPrompt
:
string
;
private
maxMessages
:
number
;
constructor
(
systemPrompt:
string
=
''
, maxMessages:
number
=
40
) {
this
.
systemPrompt
= systemPrompt;
this
.
maxMessages
= maxMessages;
if
(systemPrompt.
length
>
0
) {
this
.
messages
.
push
({
role
:
Role
.
SYSTEM
,
content
: systemPrompt });
}
}
addUser
(
text
:
string
):
void
{
this
.
messages
.
push
({
role
:
Role
.
USER
,
content
: text });
this
.
trim
();
}
addAssistant
(
text
:
string
):
void
{
this
.
messages
.
push
({
role
:
Role
.
ASSISTANT
,
content
: text });
this
.
trim
();
}
private
trim
():
void
{
const
conv =
this
.
messages
.
filter
(
m
=>
m.
role
!==
Role
.
SYSTEM
);
if
(conv.
length
0
) {
next.
push
({
role
:
Role
.
SYSTEM
,
content
:
this
.
systemPrompt
});
}
this
.
messages
= next.
concat
(keepConv);
}
clear
():
void
{
this
.
messages
=
this
.
systemPrompt
.
length
>
0
? [{
role
:
Role
.
SYSTEM
,
content
:
this
.
systemPrompt
}] : [];
}
}getMessages() 返回完整序列(系统 + 对话)直接喂给底座; getConversation() 只返回对话消息,给 UI 渲染用。裁剪只动对话消息,系统消息雷打不动——这个细节用单测锁死。
有了记忆容器, Agent 本身出乎意料地薄——它只做一件事:把"组织请求 → 调底座 → 回写记忆"的固定顺序封装成 send() 。
export
class
Agent
{
readonly
store
:
ConversationStore
;
private
client
:
LlmClient
;
private
model
:
string
;
private
temperature
:
number
;
constructor
(
client: LlmClient, systemPrompt:
string
,
model:
string
, temperature:
number
=
0.7
) {
this
.
client
= client;
this
.
store
=
new
ConversationStore
(systemPrompt);
this
.
model
= model;
this
.
temperature
= temperature;
}
async
send
(
userText
:
string
):
Promise
<
string
> {
this
.
store
.
addUser
(userText);
const
req
:
ChatRequest
= {
model
:
this
.
model
,
messages
:
this
.
store
.
getMessages
(),
temperature
:
this
.
temperature
};
const
resp
:
ChatResponse
=
await
this
.
client
.
chat
(req);
this
.
store
.
addAssistant
(resp.
text
);
return
resp.
text
;
}
}注意 send() 里发出去的是 store.getMessages() ——即带系统提示词的完整历史,而不是只有当前这句。很多"Agent 记不住上文"的 bug,根子就在这里只发了单句。
领域层的 ChatMessage 只有角色和内容,但界面关心更多:这条是谁说的、发出去没有、是成功还是失败、几点发的。 ChatUIMessage 负责这层翻译,避免在 ArkUI 组件里散落一堆判断。
export
enum
MsgStatus
{
PENDING
=
'pending'
,
DONE
=
'done'
,
ERROR
=
'error'
}
@Observed
export
class
ChatUIMessage
{
readonly
id
:
string
;
readonly
role
:
BubbleRole
;
text
:
string
;
status
:
MsgStatus
;
readonly
createdAt
:
number
;
static
fromDomain
(
m
:
ChatMessage
):
ChatUIMessage
{
const
role = m.
role
===
Role
.
USER
?
BubbleRole
.
USER
: m.
role
===
Role
.
ASSISTANT
?
BubbleRole
.
ASSISTANT
:
BubbleRole
.
SYSTEM
;
return
new
ChatUIMessage
(role, m.
content
,
MsgStatus
.
DONE
,
0
,
''
);
}
isUser
():
boolean
{
return
this
.
role
===
BubbleRole
.
USER
; }
isPending
():
boolean
{
return
this
.
status
===
MsgStatus
.
PENDING
; }
isError
():
boolean
{
return
this
.
status
===
MsgStatus
.
ERROR
; }
formattedTime
():
string
{
const
d =
new
Date
(
this
.
createdAt
);
return
d.
getHours
().
toString
().
padStart
(
2
,
'0'
) +
':'
+
d.
getMinutes
().
toString
().
padStart
(
2
,
'0'
);
}
}PENDING 状态对应"思考中…"的占位气泡, ERROR 对应红色的失败提示——状态先建模,界面才有章法。
页面骨架分两块:消息列表和输入区。发送逻辑是一个小状态机——先落两个气泡(用户气泡立即完成,助手气泡置 PENDING ),请求回来更新助手气泡,失败标 ERROR ,全程用 sending 锁住按钮防重复提交。
private
async
handleSend
():
Promise
<
void
> {
const
text =
this
.
inputText
.
trim
();
if
(text.
length
===
0
||
this
.
sending
)
return
;
this
.
inputText
=
''
;
const
userMsg =
new
ChatUIMessage
(
BubbleRole
.
USER
, text,
MsgStatus
.
DONE
);
const
botMsg =
new
ChatUIMessage
(
BubbleRole
.
ASSISTANT
,
''
,
MsgStatus
.
PENDING
);
this
.
messages
= [...
this
.
messages
, userMsg, botMsg];
this
.
sending
=
true
;
try
{
const
reply =
await
this
.
agent
.
send
(text);
botMsg.
text
= reply;
botMsg.
status
=
MsgStatus
.
DONE
;
}
catch
(e) {
botMsg.
text
=
'出错了:'
+ (e
as
Error
).
message
;
botMsg.
status
=
MsgStatus
.
ERROR
;
}
finally
{
this
.
messages
= [...
this
.
messages
];
this
.
sending
=
false
;
}
}气泡渲染用 List + ForEach ,每条消息交给独立的 MessageBubble 子组件;子组件用 @ObjectLink 绑定 @Observed 的 ChatUIMessage ,这样 assistant 气泡在"思考中 → 完成/失败"时属性一变就自动刷新,不用整体重绘列表。两个 ArkUI 惯例要记住: 一是 @State 数组改完要重新赋值 this.messages = [...this.messages] 才稳;二是要精细刷新单条气泡时,用 @Observed + @ObjectLink 而不是只靠数组重赋值。
@Component
struct
MessageBubble
{
@ObjectLink
msg
:
ChatUIMessage
;
build
(
) {
Row
() {
if
(
this
.
msg
.
isUser
()) {
Blank
() }
Column
() {
Text
(
this
.
msg
.
text
.
length
>
0
?
this
.
msg
.
text
: (
this
.
msg
.
isPending
() ?
'思考中…'
:
''
))
.
fontSize
(
15
).
lineHeight
(
22
)
.
fontColor
(
this
.
msg
.
isError
() ?
'#C0392B'
:
'#1A1A1A'
)
.
padding
(
10
)
Text
(
this
.
msg
.
formattedTime
())
.
fontSize
(
10
).
fontColor
(
'#9AA0A6'
)
.
alignSelf
(
this
.
msg
.
isUser
() ?
ItemAlign
.
End
:
ItemAlign
.
Start
)
}
.
backgroundColor
(
this
.
msg
.
isUser
() ?
'#ffced0d2'
:
'#EFF2F7'
)
.
borderRadius
(
12
).
constraintSize
({
maxWidth
:
'78%'
}).
padding
(
4
)
}
.
width
(
'100%'
)
.
justifyContent
(
this
.
msg
.
isUser
() ?
FlexAlign
.
End
:
FlexAlign
.
Start
)
.
padding
({
top
:
4
,
bottom
:
4
})
}
}脚手架的三块核心全是纯逻辑,Hypium 单测不需要网络、不需要 API Key,模拟器上秒过。
it
(
'超出上限裁剪最旧对话,系统消息保留'
,
0
,
() =>
{
const
s =
new
ConversationStore
(
'sys'
,
2
);
s.
addUser
(
'1'
); s.
addAssistant
(
'A'
);
s.
addUser
(
'2'
); s.
addAssistant
(
'B'
);
s.
addUser
(
'3'
); s.
addAssistant
(
'C'
);
expect
(s.
getMessages
()[
0
].
role
).
assertEqual
(
Role
.
SYSTEM
);
expect
(s.
getConversation
().
length
).
assertEqual
(
2
);
expect
(s.
getConversation
()[
1
].
content
).
assertEqual
(
'C'
);
});
it
(
'send 落库用户/助手并走通底座'
,
0
,
async
() => {
const
mock =
new
MockLlmClient
(
'假回复'
);
const
agent =
new
Agent
(mock,
'sys'
,
'm'
,
0.7
);
const
reply =
await
agent.
send
(
'你好'
);
expect
(reply).
assertEqual
(
'假回复'
);
const
all = agent.
store
.
getMessages
();
expect
(all[
1
].
role
).
assertEqual
(
Role
.
USER
);
expect
(all[
2
].
role
).
assertEqual
(
Role
.
ASSISTANT
);
});工程里共 19 条断言:底座复用 9 条(重试 6 + 解析 3),新增 ConversationStore 4 条、 Agent 2 条(含错误传播)、 ChatUIMessage 4 条,全部不发起网络请求。 Agent 的测试通过继承 LlmClient 写了一个 MockLlmClient , sendOnce 直接返回固定 JSON,顺带把"底座解析 → Agent 落库"的整条链路验证了。
DevEco Studio 26 打开工程,右键 ohosTest → Run ,确认 19 条断言全绿。
打开 pages/Index.ets ,把 buildAgent() 里的 baseUrl / apiKey / model 换成自己的云端网关配置。
连真机或 HarmonyOS 7 模拟器运行,输入框打字、点发送,即可看到多轮对话气泡——历史会带着系统提示词一起发给模型,Agent 记得住上文。
系统提示词别写死在页面里 :收进 ConversationStore , new Agent 时统一传入,换页面不丢人设。
多轮历史会撑爆上下文窗口 :设 maxMessages 自动裁剪最旧对话,系统消息常驻。
气泡状态不建模,UI 一团乱 :用 ChatUIMessage 区分 pending / done / error ,别在组件里写散装 if。
@State 数组直接 push 不刷新 :改完重新赋值 this.messages = [...this.messages] ;要精细刷新单条气泡,再叠加 @Observed + @ObjectLink 。
每个页面都 new 一个客户端 : Agent 复用同一个 LlmClient 实例,配置只写一处。
上篇《HarmonyOS 7 Agent 开发实战 01:云端大模型 API 接入底座》(本工程复用其 LlmClient 抽象)
华为开发者官网 · ArkTS 声明式 UI 开发指南(@Component / @State / List / ForEach)
华为开发者官网 · 应用测试框架 Hypium(ohosTest 单测)
最后一句 :脚手架不是 scaffolding 的花架子,而是让每一行新代码都有明确的归宿;Agent 的能力可以慢慢长,骨架先要站得直。
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Juejin HarmonyOS
原文作者:威哥爱编程;如有侵权,请联系管理员删除。