鸿蒙HarmonyOS Agent 记忆与子 Agent 实战 —— 上下文压缩、episodic memory、任务委派 一、前言长对话和复杂任务的两个天花板Agent 跑久了会遇到两个天花板天花板一上下文溢出。每轮对话都在累积消息历史50 轮后 prompt tokens 可能超过模型的上下文窗口比如 64000。此时模型要么报错、要么截断早期消息丢失关键信息。你需要的是压缩——把早期对话摘要成简短的 episodic memory只保留最近几轮的完整消息。天花板二任务复杂度。一个调研并写报告的任务Agent 可能需要先搜索、再分析、最后写作。如果所有工作都在一个 Agent 里做上下文会迅速膨胀且不同阶段的工具和提示词互相干扰。你需要的是委派——把这个任务的一部分交给一个子 Agent子 Agent 用独立的上下文和工具集完成工作把结果汇报给父 Agent。但这两个能力都有自己的陷阱压缩失败不能丢历史事务式委派不能无限递归失控子 Agent 不能执行危险工具安全。ArkAgent 用 ContextCompressor SubAgent SubAgentPolicy 解决这些问题。二、ContextCompressor事务式压缩ContextCompressormemory/ContextCompressor.ets是一个 SPI 接口负责把长对话历史压缩成 episodic memory。2.1 SPI 契约事务式/** * SPI: compress long conversation history into episodic memory. * Implementations must be transactional: no partial history mutation on failure. */ export interface ContextCompressor { compress(state: AgentState, context: CompressContext): PromiseCompressResult }最关键的约束Implementations must be transactional: no partial history mutation on failure.——压缩失败时历史不能被部分修改。要么完整压缩成功要么历史原封不动。2.2 CompressResult三种结果export enum CompressOutcome { skipped skipped, // 跳过不需要压缩 compressed compressed, // 成功压缩 failed failed // 压缩失败历史不变 }skipped的常见原因没有 usage 数据、历史太短、token 数低于阈值、上一次就是压缩调用。2.3 触发条件constructor( client: LLMClient, modelConfig: ModelConfig, totalTokenThreshold: number 64000, // token 阈值 keepRecentMessageCount: number 10, // 保留最近 10 条 summaryPrompt: string DEFAULT_SUMMARY_PROMPT, minMessagesToSummarize: number 3 )压缩触发的判断逻辑// 没有 usage → skip if (state.usages.length 0) return CompressResult.skipped(no_usage) // 历史太短 → skip if (state.history.messages.length this.keepRecentMessageCount) return CompressResult.skipped(history_too_short) // token 数低于阈值 → skip const promptTokens lastUsage.promptTokens if (promptTokens undefined || promptTokens this.totalTokenThreshold) return CompressResult.skipped(below_threshold) // 上一次就是压缩调用 → skip防止压缩循环 if (lastUsage.raw?.get(source)?.asString() context_compression) return CompressResult.skipped(last_usage_is_compression)最后一条很关键——防止压缩循环。压缩本身也会消耗 token调用 LLM 生成摘要如果不跳过压缩生成的 usage 又会触发下一次压缩陷入无限循环。2.4 安全切分点压缩需要把历史切成两部分前面的压缩成摘要后面的保留原样。切分点不能在 Tool Call/Result 中间切开——否则两侧都有不配对的 Call/Result。export function computeSafeSplitIndex(messages: AgentMessage[], keepRecentMessageCount: number): number { if (messages.length keepRecentMessageCount) { return -1 } let splitIndex messages.length - keepRecentMessageCount while (splitIndex 0) { const compressPart messages.slice(0, splitIndex) const keepPart messages.slice(splitIndex) // 两侧都必须配对完整 if (HistoryInvariants.toolCallsPaired(compressPart) HistoryInvariants.toolCallsPaired(keepPart)) { return splitIndex } splitIndex-- // 不安全就往前挪 } return -1 // 找不到安全切分点 }2.5 原子提交// --- Atomic commit (history first; usage attribution only after success) --- state.history.episodicMemories.push(episode) state.history.messages newMessages if (!HistoryInvariants.toolCallsPaired(newMessages)) { return CompressResult.failed(post_commit_pairing_violation, usage) } // 只有 history 提交成功后才归因 usage if (usage ! undefined context.onUsage ! undefined) { context.onUsage(usage) }usage 归因的顺序先提交历史后归因 usage。如果压缩失败LLM 返回空摘要/畸形 JSONusage 不归因——CompressContext.onUsage只在成功路径调用。Runtime 侧也做了暂存stagedUsage只在CompressOutcome.compressed时写入state.usagesfailed/skipped 全量回滚 history usages。2.6 Runtime 侧的压缩调度与全量回滚Runtime 的maybeCompressContext方法是压缩的调度入口。它在每轮 Agent Loop 结束后判断是否需要压缩并负责全量回滚// runtime/AgentRuntime.ets — maybeCompressContext private async maybeCompressContext(ctx: RunContext): Promisevoid { if (this.config.contextCompressor undefined) return // 暂存当前状态的快照失败时全量回滚 const before this.state.history.clone() const usagesBefore this.state.usages.slice() let stagedUsage: ModelUsage | undefined undefined const onUsage (usage: ModelUsage): void { stagedUsage usage } const result await this.config.contextCompressor.compress(this.state, new CompressContext(this.config.idGenerator, ctx.signal, onUsage)) if (result.outcome ! CompressOutcome.compressed || result.episodeId undefined) { // failed | skipped: 全量回滚 history 和 usages this.state.history before this.state.usages usagesBefore.slice() stagedUsage undefined return } // 成功路径提交暂存的 usage const usageToRecord stagedUsage ! undefined ? stagedUsage : result.usage if (usageToRecord ! undefined) { this.state.usages.push(usageToRecord) } }这段代码体现了事务式的核心——暂存staging 条件提交conditional commit。before和usagesBefore是回滚快照stagedUsage是暂存的 usage不直接写入 state.usages。只有CompressOutcome.compressed时才真正提交。2.7 DEFAULT_SUMMARY_PROMPT结构化摘要压缩调用的 LLM 不是随便总结一下而是要求输出结构化的 XML 摘要DEFAULT_SUMMARY_PROMPT 要求 LLM 输出 state_snapshot XML overall_goal任务的总体目标/overall_goal key_knowledge关键事实和约束/key_knowledge file_system_state文件系统状态/file_system_state recent_actions最近的操作/recent_actions current_plan当前计划/current_plan这个结构化摘要被存入EpisodicMemory.summary模型可以通过retrieve_memory工具检索它。结构化的好处是摘要不会丢失关键信息比如用户要的是法棍的失重率不是吐司的模型恢复时有明确的上下文锚点。三、MemoryService窄接口MemoryServicememory/MemoryService.ets提供对 episodic memory 的只读访问。3.1 窄接口设计export interface MemoryService extends ToolService { listEpisodes(): EpisodicMemory[] findEpisode(snapshotId: string): EpisodicMemory | undefined }注释Session-scoped read access to episodic memories.Tools never receive a mutable AgentState handle (ADR-0013).只读——工具通过 MemoryService 读取历史 episodic memory但不能修改 Agent 状态。RuntimeMemoryService通过 getter 函数获取 live episodes保证 resume/compress 后能读到最新数据。3.2 retrieve_memory 工具RETRIEVE_MEMORY_TOOL_NAME retrieve_memory DEFAULT_MEMORY_LIMIT 20 MAX_MEMORY_LIMIT 100模型可以调用retrieve_memory工具搜索历史 episodic memory参数支持snapshot_id、offset、limit。每个参数都有严格校验snapshot_id不能空、offset非负整数、limit不超过 100。四、SubAgent任务委派SubAgentmemory/SubAgent.ets实现了任务委派——父 Agent 可以把一部分工作交给子 Agent。4.1 委派模型DELEGATE_TASK_TOOL_NAME delegate_task CLONE_ASSIGNEE clone SUB_AGENT_SERVICE_ID arkagent.sub_agent父 Agent 通过delegate_task工具委派任务参数包含assignee执行者clone表示克隆父 Agent或命名 workertask_description任务描述delegate_task的工具定义JSON Schemafunction delegateTaskDefinition(): ToolDefinition { const assigneeProp new JsonObject() .set(type, JsonValue.string(string)) .set(description, JsonValue.string( Worker name or clone. clone reuses the parent config.)) const taskProp new JsonObject() .set(type, JsonValue.string(string)) .set(description, JsonValue.string(Natural-language task description.)) const props new JsonObject() .set(assignee, JsonValue.object(assigneeProp)) .set(task_description, JsonValue.object(taskProp)) return new ToolDefinition( delegate_task, Delegate a sub-task to a worker sub-agent with isolated context., new JsonObject() .set(type, JsonValue.string(object)) .set(properties, JsonValue.object(props)) .set(required, JsonValue.array([ JsonValue.string(assignee), JsonValue.string(task_description) ])) ) }4.2 SubAgentDefinition命名 worker 的工厂命名 worker 通过SubAgentDefinition注册每个 worker 有一个SubAgentFactory——由宿主 App 提供的工厂函数负责创建 worker 的AgentRuntimeConfigexport class SubAgentDefinition { readonly name: string readonly description: string readonly factory: SubAgentFactory static create(name: string, description: string, factory: SubAgentFactory): ResultSubAgentDefinition, ArkAgentError { const trimmed name.trim() if (trimmed.length 0) { return Result.failure(ArkAgentError.config(subagent_name_empty, ...)) } // clone 是保留名称不能用作 worker 名 if (trimmed.toLowerCase() CLONE_ASSIGNEE) { return Result.failure(ArkAgentError.config(subagent_name_reserved, Sub-agent name clone is reserved)) } return Result.success(new SubAgentDefinition(trimmed, description, factory)) } }关键约束clone是保留名称——不能注册一个叫 clone 的 worker因为它和克隆父 Agent的 assignee 冲突。4.2 SubAgentPolicy委派上限ADR-0013 冻结了委派的预算策略export class SubAgentPolicy { readonly maxDepth: number // 默认 1 readonly maxDelegationsPerRun: number // 默认 4 readonly maxConcurrentChildren: number // 默认 2 readonly parentContextMessageCount: number // 默认 10 constructor( maxDepth: number 1, maxDelegationsPerRun: number 4, maxConcurrentChildren: number 2, parentContextMessageCount: number 10 ) }参数默认含义maxDepth1最大递归深度子 Agent 不能再委派maxDelegationsPerRun4单次 run 最多委派次数maxConcurrentChildren2最大并发子 AgentparentContextMessageCount10注入多少条父消息作为上下文4.3 预算检查委派前检查预算超出则拒绝if (depth policy.maxDepth) { return delegateError(subagent_depth_exceeded, ...) } if (this.delegationsThisRun policy.maxDelegationsPerRun) { return delegateError(subagent_run_budget_exceeded, ...) } if (this.activeChildren.size policy.maxConcurrentChildren) { return delegateError(subagent_concurrent_budget_exceeded, ...) }五、⚠️ 踩坑malicious factory delegate_task 安全加固5.1 症状阶段 7 Codex 首轮发现的 P1 安全问题Worker 基座 Registry 仍然可能包含delegate_task——恶意的 SubAgentFactory 可以自定义执行器在 worker 的 Registry 里重新注册 delegate_task绕过子 Agent 不能再委派的限制。5.2 根因forceWorkerConfig虽然过滤了delegate_task但如果 factory 在创建后重新注册了它过滤就被绕过了。5.3 修复双重拦截export function buildWorkerToolRegistry(source: ToolRegistry): ToolRegistry { const tools new ToolRegistry() source.copyIntoExcluding(tools, [DELEGATE_TASK_TOOL_NAME]) // 复制时排除 tools.unregister(DELEGATE_TASK_TOOL_NAME) // 防御性二次 unregister return tools }forceWorkerConfig中也硬性剥离// Hard strip: even a malicious factory registering delegate_task is removed. const tools buildWorkerToolRegistry(source.toolRegistry) if (tools.has(DELEGATE_TASK_TOOL_NAME)) { tools.unregister(DELEGATE_TASK_TOOL_NAME) // 绝对保证 }加上 Runtime 执行入口的isSubAgent检查子 Agent 再调 delegate_task 直接拒绝subagent_worker_cannot_delegate形成双重拦截。回归测试malicious_factory_delegate_task断言evil.callCount0——恶意执行器永远不会被调用。5.4 压缩 Usage 触发压缩循环阶段 7 报告还记录了另一个压缩相关的踩坑压缩调用本身会消耗 token 并产生 usage这个 usage 如果进入state.usages下次检查阈值时会发现token 数还是很高又触发压缩——形成无限循环。修复方案压缩产生的 usage 在raw字段标记source: context_compression触发检查时跳过这个来源// 触发条件的最后一道检查 if (lastUsage.raw?.get(source)?.asString() context_compression) { return CompressResult.skipped(last_usage_is_compression) }5.5 ArkTS 禁止对象字面量类型阶段 7 报告踩坑一现象validateEpisodicMemoryIds返回类型、bridge Observer 字面量编译失败根因arkts-no-obj-literals-as-types/no-structural-typing方案显式 classEpisodeIdValidation、SubAgentBridgeObserverArkTS 不允许用对象字面量类型{ id: string, valid: boolean }作为返回类型也不允许结构类型duck typing。所有看起来像接口的数据结构都必须用显式 class 定义。这和第五篇博客讲的arkts-no-untyped-obj-literals是同一组约束的变体。六、事件桥接与 usage 聚合6.1 子 Agent 事件桥接子 Agent 的事件通过SubAgentBridgeObserver桥接到父 Controller携带完整关联字段this.parent.publishControllerPublic(new SubAgentChildEvent( this.parentSessionId, this.parentRunId, this.childSessionId, event.runId, this.assignee, this.taskDescription, this.depth, event))每个 child 事件都带 parent session/run、child session/run、task/assignee、depth——让父 Agent 的观察者能完整追踪子 Agent 的行为。6.2 usage 只汇总不合并历史ADR-0013 决策 5 的关键约束child Usage 形成结构化汇总并进入 delegate ToolResult metadata是否计入父级聚合统计必须只有一个确定入口禁止同时复制到父state.usages造成重复计费。child 消息历史不合并进父历史。const usages childRuntime.getUsages() const usageSummary ChildUsageSummary.fromUsages(usages) // Never merge child usages into parent state.usages (ADR-0013).子 Agent 的 usage 聚合成一个ChildUsageSummaryprompt/completion/total/callCount放入 delegate ToolResult 的 metadata。绝不把子 Agent 的每条 usage 都追加到父 Agent 的state.usages——那会造成重复计费。ChildUsageSummary的结构export class ChildUsageSummary { readonly promptTokens: number // 子 Agent 所有调用的 prompt 总和 readonly completionTokens: number // 子 Agent 所有调用的 completion 总和 readonly totalTokens: number readonly callCount: number // 子 Agent 总共调了多少次 LLM static fromUsages(usages: ModelUsage[]): ChildUsageSummary { // 聚合所有 usage 的 prompt/completion/total/callCount } toJson(): JsonObject { // 序列化进 ToolResult metadata } }6.3 子 Agent 失败的安全处理子 Agent 执行失败时失败信息只暴露错误码不回传原始异常文本。阶段 7 报告的实施心得记录了这条子 Agent 失败消息只暴露 code不回传原始异常文本防密钥泄露。为什么因为原始异常文本可能包含 Provider 返回的完整错误 body里面可能有 Authorization Header 片段或其他敏感信息。只暴露结构化的错误码如subagent_depth_exceeded既让父 Agent 知道发生了什么又不会泄漏敏感数据。6.4 allocateIsolatedSessionId子 session ID 隔离子 Agent 必须有独立的 sessionId不能和父 Agent 或其他子 Agent 冲突export function allocateIsolatedSessionId( parentSessionId: string, requested: string, activeIds: string[], nextId: () string ): string { const parentId parentSessionId.trim() const trimmed requested.trim() const active new Setstring() for (let i 0; i activeIds.length; i) { active.add(activeIds[i]) } // 请求的名称有效且空闲 → 使用它 if (trimmed.length 0 trimmed ! parentId !active.has(trimmed)) { return trimmed } // 否则强制生成{parentId}_w_{nextId()} let candidate ${parentId}_w_${nextId()} let guard 0 while ((candidate.length 0 || candidate parentId || active.has(candidate)) guard 16) { candidate ${parentId}_w_${nextId()} guard } return candidate }三层保护不能等于 parentId防止子父冲突、不能在 active 集合中防止兄弟冲突、guard 限制最多重试 16 次防止无限循环。七、最佳实践清单7.1 压缩✅ 压缩必须事务式——失败不部分修改历史。✅ 切分点不能切开 Tool Call/Result 配对。✅ 压缩自身产生的 usage 标记 source防止压缩循环。✅ usage 只在历史提交成功后归因。7.2 子 Agent✅ maxDepth 默认 1子 Agent 不能再委派。✅ buildWorkerToolRegistry 过滤 delegate_task 防御性 unregister。✅ Runtime 入口检查 isSubAgent双重拦截递归委派。✅ 子 Agent 事件带完整关联字段。✅ usage 只汇总不合并历史。7.3 Memory✅ MemoryService 是只读窄接口。✅ 工具不直接操作 AgentState。八、常见错误对照表错误做法问题正确做法压缩失败部分修改历史历史不一致事务式失败全量回滚切分点切开 Tool 配对两侧都有孤儿 Call/ResultcomputeSafeSplitIndex压缩 usage 不标记 source压缩循环sourcecontext_compression子 Agent 能再委派无限递归maxDepth1 isSubAgent 检查Worker Registry 含 delegate_task恶意 factory 绕过buildWorkerToolRegistry 双重过滤合并子 Agent usage 到父重复计费只汇总 ChildUsageSummary合并子 Agent 历史到父上下文膨胀子历史不合并九、验证清单压缩超过阈值触发压缩压缩成功后历史缩短压缩失败后历史不变安全切分点不切断 Tool 配对压缩 usage 不触发循环子 Agentdelegate_task 正确委派maxDepth 超出拒绝maxDelegations 超出拒绝maxConcurrent 超出拒绝恶意 factory 不执行callCount0子 Agent 事件带关联字段usage 只汇总不合并十、构建验证NODE_HOME/Applications/DevEco-Studio.app/Contents/tools/node \ DEVECO_SDK_HOME/Applications/DevEco-Studio.app/Contents/sdk \ /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemonCompileArkTS passed BUILD SUCCESSFUL测试覆盖MemorySubAgent.test.ets压缩/子 Agent/安全隔离/usage 聚合。十一、写在最后压缩事务不丢史切分点要保配对。 委派上限四参数深度一并发二。 Worker 剥离 delegate双重拦截递归。 子 Agent usage 只汇总历史不合并计费不重。 Memory 窄接口只读看工具不碰 State 事。