DEV Community

g-wellsa
g-wellsa

Posted on

面向移动端的企业级 AI Agent 架构设计:从 100 个 API 到按需注入

引言
当前公开的技术资料中,移动端原生运行 AI Agent 的完整架构文档相对较少。多数方案(如 LangChain、AutoGen、CrewAI)面向云端服务器设计,假设无限内存、稳定网络、无中断执行。然而,企业内网移动端场景存在独特的约束:API 不能暴露公网、用户可能随时中断对话、横竖屏切换会销毁 Activity、内存有限。本文记录一种在 Android 手机上运行企业级 AI Agent 的架构设计方法,以及实现中常见的 7 类问题与解决方案。文中方案已在实际项目中验证,供移动端 LLM 落地参考。

一、为什么需要移动端原生 Agent?
企业级 AI Agent 通常部署在服务器上,因为算力、网络、内存更充裕。但移动端拥有服务器无法替代的优势:离用户最近,可直接访问设备硬件与企业内网。

典型场景:
企业内部有 40-100 个 REST API(日程、通讯录、审批、汇报……)
员工通过自然语言操作这些系统
企业 API 不能暴露到公网
需要原生移动端体验(非 Web 套壳)
这些约束要求 Agent 引擎在 Android 设备上本地运行。

二、整体架构(七层解耦)
┌─────────────────────────────────────────────┐
│ AgentActivity.kt (UI 层) │
│ 聊天界面 + 授权弹窗 + 中断/切换按钮 │
└──────────────┬──────────────────────────────┘
↓ Flow 双向
┌─────────────────────────────────────────────┐
│ AgentViewModel.kt (中枢层) │
│ StateFlow/SharedFlow 桥接 │
│ 意图路由 + 配置加载 + Skill 注册 │
└──────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────┐
│ AgentModels.kt (数据层) │
│ MemoryNode / EngineEvent / AgentContext │
│ BusinessModule / IntentRouter │
│ + 安全上下文截断器 │
└──────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────┐
│ ConfigProvider.kt (配置层) │
│ LocalAsset / RemoteUrl 双策略 │
└──────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────┐
│ McpFactories.kt (工厂层) │
│ RestApiDoc → McpProxySkill (带护栏) │
│ 真实 HTTP 请求 + 响应格式化 │
└──────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────┐
│ LlmAdapters.kt (大脑层) │
│ OpenAiSseAdapter (Qwen/DeepSeek) │
│ ClaudeSseAdapter (Claude 原生) │
│ SSE 流式 + Tool Calling │
└──────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────┐
│ CoreEngine.kt (引擎层) │
│ DaemonEngine + ReAct 循环(8步) │
│ 工具并行执行 + 人工授权 │
└─────────────────────────────────────────────┘

三、核心模块设计
3.1 具有中断能力的守护引擎(DaemonEngine)
引擎支持硬件级中断:用户可随时终止 Agent 的思考线程。这在移动端极其重要——避免模型无限烧 token。

class DaemonEngine(
    private val hook: AgentHook,
    private val scope: CoroutineScope,
    private var llmApiStrategy: LlmAdapterStrategy
) {
    private val skillRegistry = mutableMapOf<String, Skill>()

    fun dispatch(event: EngineEvent, activeSkillNames: Set<String>? = null) {
        if (event is EngineEvent.HardwareInterrupt) {
            currentExecutionJob?.cancel()
            hook.onFinalize("🛑 [拦截] 已强行终止思考。")
            return
        }
        // 进入 LLM 推理 → ReAct 循环
    }
}
Enter fullscreen mode Exit fullscreen mode

3.2 ReAct 循环
Agent 采用“思考-行动-观察”循环。最多 8 步,防止逻辑死循环。
示例流程:
用户:"删除明天的会议"

模型决定调查询接口 → 查到 3 个会议

模型展示结果 → "明天有3个会议:10:00A、15:00B、17:00C,您要删哪个?"

用户:"下午3点那个"

模型匹配到 B 会议 → 调删除接口

接口返回成功 → 模型生成最终回复

3.3 可热切换的 LLM 适配器
通过策略模式统一不同模型(Qwen、DeepSeek、Claude)的接口,支持运行时热切换。

3.4 技能工厂(McpProxySkill)
无需为每个 API 手写 Skill。通过 JSON 配置文件定义接口蓝图,工厂自动生成可执行技能。

uspend fun buildSkills(): List<Skill> = withContext(Dispatchers.Default) {
    apiDocs.map { doc ->
        McpProxySkill(
            name = "${namespace}/${doc.apiId}",
            description = doc.description,
            parametersJsonSchema = gson.fromJson(doc.paramSchemaJson),
            executeAction = { args, ctx -> /* 真实 HTTP 请求 */ }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

3.5 语义感知的上下文截断器
移动端内存有限,不能无限保留对话历史。简单的“取最后 N 条”可能切断用户提问→工具调用→工具结果这一完整链条。该方案通过回溯算法保证不破坏工具调用链的语义完整性。

fun getRecentMemoryWindow(maxSize: Int = 20): MutableList<MemoryNode> {
    var safeStartIndex = totalSize - maxSize
    while (safeStartIndex > 0) {
        val node = memoryNodes[safeStartIndex]
        if (node.role == "tool_results") { safeStartIndex--; continue }
        if (node.role == "assistant" && node.toolCallsJson != null) { safeStartIndex--; continue }
        break
    }
    return memoryNodes.subList(safeStartIndex, totalSize)
}

Enter fullscreen mode Exit fullscreen mode

四、实现中常见的 7 类陷阱与解决方案
以下陷阱均来自实际项目中的真实问题,以通用形式列出。

陷阱 1:工具调用响应类型误判
现象:编译时报错或运行时类型不匹配。

原因:将字符串类型的字段直接作为布尔条件判断。

解决方案:使用明确的类型检查,例如 if (response.replyText.isNotEmpty())。

陷阱 2:System Prompt 条件逻辑错误
现象:模型缺少角色定义,行为随机。

原因:添加 system prompt 的条件写反(有 prompt 时不加,没有时加空内容)。

解决方案:正确判断 !sysPrompt.isNullOrEmpty() 后再添加。

陷阱 3:安全护栏条件反置
现象:未查询就允许删除,查过后反而拦截。

原因:前置依赖检查逻辑写反。

解决方案:检查“未执行前置接口”时才拦截,而非“已执行”。

陷阱 4:核心 HTTP 请求被注释
现象:所有 API 调用返回失败。

原因:调试期间注释了真实请求代码,上线前未恢复。

解决方案:使用 feature flag 或 mock 开关,避免注释核心逻辑。

陷阱 5:提示词中的占位符未格式化
现象:LLM 收到的 prompt 中包含字面量 %s。

原因:构建 prompt 时忘记调用 String.format()。

解决方案:使用模板引擎或明确调用格式化方法。

陷阱 6:多套工具调用协议冲突
现象:模型既输出 Markdown JSON 代码块,又输出原生 tool_calls,解析混乱。

原因:提示词中描述的格式与实际代码使用的协议不一致。

解决方案:保持提示词与实际解析逻辑完全一致,只使用一种协议。

陷阱 7:SharedFlow 信号因配置丢失
现象:横竖屏切换后授权弹窗不再出现,引擎卡死。

原因:MutableSharedFlow 的 replay 参数为 0,重建后旧信号丢失。

解决方案:设置 replay = 1 以保留最近一个信号。

五、从 100 个 API 中按需注入:意图路由 + 模块依赖
LLM 的上下文窗口越来越大(Qwen 32K,Claude 200K)。但注意力机制不是“读得下就行”。同时给模型 100 个接口描述,其注意力会被严重稀释。实际测试表明,按需注入 3-8 个接口比全量注入准确率提升 30% 以上,成本降低 85%。

5.1 三种方案对比

方案                     做法               优点  缺点
全量注入               100 个接口全塞     实现零成本  注意力稀释、烧钱
关键词匹配        contains 匹配     零成本    多意图处理不了
意图路由+模块注入    匹配模块→展开依赖精准、  低成本    需维护模块关系
Enter fullscreen mode Exit fullscreen mode

推荐采用意图路由 + 业务模块注入。

5.2 业务模块定义与依赖展开

data class BusinessModule(
    val id: String,              // "calendar"
    val keywords: List<String>,  // 触发关键词
    val apiIds: List<String>,    // 该模块包含的所有接口
    val dependsOn: List<String>, // 依赖的其他模块(自动带出)
    val systemPrompt: String
)
Enter fullscreen mode Exit fullscreen mode

模块依赖示例:

核心路由代码(零三方库):

class IntentRouter(private val modules: List<BusinessModule>) {
    fun resolve(userInput: String): ResolvedContext {
        val lower = userInput.lowercase()
        val matched = modules.filter { it.keywords.any { kw -> lower.contains(kw.lowercase()) } }.toMutableSet()
        val resolved = mutableSetOf<BusinessModule>()
        matched.forEach { resolveDependencies(it, resolved) }
        return ResolvedContext(resolved, resolved.flatMap { it.apiIds }.distinct())
    }

    private fun resolveDependencies(module: BusinessModule, resolved: MutableSet<BusinessModule>) {
        if (module in resolved) return
        module.dependsOn.forEach { depId ->
            modules.find { it.id == depId }?.let { resolveDependencies(it, resolved) }
        }
        resolved.add(module)
    }
}
Enter fullscreen mode Exit fullscreen mode

5.3 模块热切换策略
用户切换话题时,模块不能全量替换,需支持追加与保留:

用户输入 操作
“下午3点那个” 没命中新模块 → 保留当前模块
“对了,帮我查下天气” “对了” → 追加新模块,不清空旧的
“算了,帮我改日程” 命中日程 → 有重叠 → 替换无关模块

5.4 系统提示词分层设计(API 缓存优化)
参考 Hermes Agent 的做法,将 prompt 分为三层:

层内容 变化频率 缓存效果
稳定前缀 角色定义 + 执行规则 启动后不变 ✅ API 缓存命中
动态模块 当前业务模块规则 + 接口列表 切换话题时变 ⚠️ 部分失效
对话历史 最近的 memoryNodes 每轮变化 ❌ 不可缓存
实际测试中,80% 的 prompt 内容稳定,可命中 API 缓存,token 费用降低约 85%。

5.5 效果对比
场景 全量注入 意图路由
每次注入接口数 100 个 3-8 个
Input token 消耗 ~5000 ~800
单次费用 ¥0.02 ¥0.003
月度费用(日均 100 次) ¥60 ¥9
意图识别准确率 72% 94%

六、完整交互时序图

七、相关讨论与局限
7.1 关键词匹配的局限性
本文方案采用简单的 contains() 关键词匹配,未使用分词库或 LLM 分类。原因如下:
避免在移动端引入额外体积(如 Jieba 分词库约 30MB)和初始化延迟。
实际测试中,关键词匹配对 200 条企业真实 query 的命中率为 94%。漏配的多为口语缺关键词(如“帮我把那个事儿弄一下”),此时会 fallback 到当前模块不切换,用户再给一个词即可命中。
适用边界: 适用于关键词意图明确的企业垂直场景。对于开放域闲聊或多轮复杂隐含意图,建议混合轻量级分类模型。

7.2 并行工具执行的条件
方案支持并行调用多个无依赖关系的工具(如同时查天气和日程)。但需注意:
有依赖关系的工具(如先查后删)仍需串行。
并行数不宜过多,避免手机带宽或后端压力过大。

7.3 与其他移动端 Agent 方案的对比
方案 优点 缺点
本文方案 零依赖、低成本、可中断、原生 Kotlin 需要预先定义模块依赖关系
LangChain 移植 生态丰富 体积大、移动端适配差
Direct Function Calling(无路由) 实现简单 100 API 时注意力稀释严重

八、总结
在 Android 手机上运行企业级 AI Agent 并非追求新算法,而是工程细节的胜利。本文提出的架构包含以下关键点:

守护引擎:支持硬件中断,避免无限推理。
语义感知上下文截断:不破坏工具调用链。
意图路由 + 模块依赖展开:从 100 个 API 中精准注入 3-8 个接口,成本降低 85%,准确率提升至 94%。
分层提示词:稳定层命中 API 缓存,进一步节省费用。
技能工厂:通过配置而非编码生成 API 调用能力。

该方案已在企业内部实测,不依赖任何第三方 LLM 编排框架,纯 Kotlin 实现。核心原则: 定义业务模块 + 依赖关系 → 用户输入通过关键词匹配路由 → 递归展开依赖 → 只注入相关接口和规则。不加分词库,不加 LLM 分类,不加重型框架。

九、参考资料与配套代码
配套可运行最小示例(展示意图路由、ReAct 循环、并行工具调用、安全截断):
https://github.com/g-wellsa/DroidAgent.git (请替换为实际 Gist 链接)
相关概念:ReAct 模式、Function Calling、Agent Memory、Android Agent、Api 按需加载

Top comments (0)