ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

无头架构实战:让Agent核心逻辑摆脱UI束缚,从Shopify弃React Native看架构解耦

无头架构实战:让Agent核心逻辑摆脱UI束缚,从Shopify弃React Native看架构解耦 上周在 Hacker News 上看到 Shopify 弃用 React Native 的帖子1272 分评论区吵了整整两天。官方的理由零零散散分布在博客和评论区里启动白屏、依赖碎片化、维护成本太高归根结底其实是一句话——移动端业务逻辑被 React Native 这个“壳”绑架了。大多数讨论集中在 RN 是不是要完了、Flutter 是不是赢家但真正让我停下来的是 Shopify 在重构里反复强调的那件事要 headless把业务核心从 UI 框架里彻底拿出来让所有前端都变成可以随时换掉的“头”。这个思想放到现在最热的 Agent 开发里恰好能解决我最近最头疼的架构问题——Agent 的逻辑不能绑死在一个 chat 组件上否则每换一个载体终端、聊天框、手机 App就得重写一遍。所以我花了两个周末叠加上班的晚上复现了一套给 Agent 用的无头架构核心引擎独立成包CLI 和 HTTP API 两个头共享同一套逻辑。期间跑了真实任务踩了四五个坑每一步都在下面。如果你是写移动端、做 Agent 框架设计、或者正在纠结“我到底要不要上 LangChain”这篇应该能给你一个完全不同的切入角度。1. 事件脉络Shopify 为什么离开 React Native1.1 这不是“技术不好”是“技术不适合”先说结论Shopify 弃用 React Native不代表 RN 是垃圾只代表它在一个对启动速度和交互体验极其敏感的场景里不再适合当主架构。RN 的启动白屏问题做过电商类 App 的人应该秒懂。RN 应用启动时原生容器先起来但 JS Bundle 还要走加载、解析、执行这一整条链路。在这段时间里用户看到的就是一个空白页面。白屏一秒钟对普通工具类应用可能无所谓但对 Shopify 这种依赖商品卡片、折扣标签、库存状态去驱动转化率的电商场景每一帧延迟都在流失订单。帖子里的技术细节我没有逐一验证但从评论区技术员工的发言能拼出个大致结论RN 在复杂页面上的桥通信、依赖冲突、Android 和 iOS 双端行为差异最终凑成了一个巨额的技术债。更值得注意的是这大概率不是一个纯技术部门的决定而是一个平台工程团队的决定。当 RN 的维护成本开始挤压新业务开发时间当新来的工程师要花两周才能搞懂自定义原生模块弃用就只是时间问题。技术选型里最容易被低估的问题就是“组织是否有能力长期接住这个框架”。1.2 社区在吵什么HN 1272 分背后其实吵了三层内容。第一层是“RN 是不是不行了”第二层是“Flutter 是不是赢了”第三层才是最有价值的——“如果 UI 本来就该是可以换的壳那为什么还要把业务核心焊死在任何一个框架里”。我刷了大部分高赞评论有一条印象很深“React Native 不是烂而是被当成了所有问题的答案。电商需要的是快不是跨端。” 另一条说“真正值得复用的是业务逻辑不是 UI。UI 会过时逻辑不会。” 还有人提到Shopify 自己就是 headless commerce 的重要玩家连商品、库存、订单都能无头化那移动端 App 为什么要和 RN 绑定这几条评论放在一起指向的其实是同一个方向无头化。相比之下单纯的“RN vs Flutter”对比反而显得太表面。我用一个表把三类方案拆开看维度React NativeFlutter无头架构UI 自由度中依赖 JS 生态高自绘引擎完全自由UI 只是适配层业务复用范围共享大部分前端逻辑仅限 Flutter 环境跨语言、跨端、跨载体核心风险原生桥与依赖碎片化引擎体积、渲染一致性协议设计复杂度维护成本中高桥代码难维护中版本升级痛低核心包独立测试社区吵到最后很多人的共识是选什么 UI 框架根本不是重点重点是你有没有把“核心里不可变的东西”和“随时可以换的东西”分开。1.3 从事件里提取的三个可复用原则从 Shopify 的决策里我提取出三个可以直接搬到 Agent 开发里的原则。第一个业务逻辑与视图解耦。在 RN 项目里商品渲染是视图下单流程里的价格计算、库存扣除是逻辑。逻辑一旦和视图耦合换框架就等于重写业务。对应到 Agent 开发里“视图”就是聊天窗口、工具调用结果的展示、进度条“逻辑”就是 Agent 怎么规划、怎么选工具、怎么记忆上下文。这两部分必须分开。第二个协议先行。Shopify 的无头电商模式里商品、订单、库存都通过 GraphQL API 对外暴露界面只是 API 的消费者。Agent 开发里同样需要工具协议、消息协议、状态协议。先定义清楚“一个 Agent 能调什么工具、工具入参出参是什么样”再考虑怎么展示结果。第三个用适配层隔离变化。RN 的桥接层把 JS 和原生连起来但桥本身成了脆弱点。无头架构要求你用一个稳定的适配层去接外部变化而不是让外部变化直接渗透到核心逻辑。Agent 的模型可以换、工具可以换、UI 可以换但核心的事件循环不能跟着一起摇摆。2. 无头架构设计思路给 Agent 用之前先把概念掰开2.1 无头架构到底是什么无头架构英文叫 Headless Architecture。所谓“头”就是用户界面“身体”是背后的业务逻辑和数据。无头就是“身体和头分离”核心逻辑自己活着通过 API 和外界交流至于外面长什么样随便换。用一个生活化类比理解餐厅的后厨和前厅。后厨负责做菜核心逻辑前厅负责点单和上菜UI。菜单就是 API。你可以做线下门店可以上外卖平台可以做无人配送只要菜单一致后厨完全不用重写。无头 CMS 也是这个道理内容管理和页面展示解耦编辑后台管内容前端随便用什么框架渲染。Shopify 自己的 headless commerce 产品就是这么玩的商品、购物车、订单、库存全部通过 API 暴露商家可以自己写 iOS App、Web 页面、线下收银系统甚至智能音箱上的购物体验。核心交易能力不依赖任何一个“头”。Agent 场景里“头”就是用户看到的界面和交互入口“身体”就是 Agent 的任务规划、工具调用、记忆管理和状态流转。大多数 Agent 项目最大的问题是把这两个东西写在了同一个文件里。2.2 为什么 Agent 一定要拥抱无头架构Agent 和传统软件有一个本质区别Agent 的载体太多了。同样一个“帮我查天气并写进日程”的任务可能来自网页聊天框、Slack 机器人、CLI 命令、手机通知栏甚至可能来自另一个 Agent。如果你的规划逻辑、工具注册、记忆管理全部写在聊天组件里每加一个载体就是一次重写。此外Agent 的“UI”并不是按钮和页面而是工具调用结果和状态反馈。用户在乎的不是你用了 React 还是 Vue而是“Agent 正在调用什么工具”“这一步花了多少钱”“现在卡住了还是正常跑”。这些反馈本质上就是一段流式输出用 CLI 可以打印用 HTTP 可以推送 SSE跟具体框架毫无关系。热词里的“agent框架”和“skill”也在这里交汇。Agent 框架提供的是规划、记忆、工具调用的编排能力Skill 是封装好的能力单元通常等于一组提示词加一组工具调用序列。而无头架构保证的是Skill 和框架核心可以脱离任何 UI 独立运行、独立测试。你完全可以写一个不打开浏览器的 Agent 核心。2.3 我给这次复现定的目标为了不变成又一次“面向 ChatGPT 编程”我给这次复现定了五个硬指标:核心引擎完全独立不依赖 React、RN、Express 之外的任何 UI 框架。至少提供两个“头”一个 CLI 交互终端一个 HTTP API共用同一个核心包。支持工具注册核心引擎能按名字调用工具工具定义带参数 Schema。支持记忆至少做到按会话隔离长期记忆可以落地到文件。全程可观测每一步工具调用、状态变化都能被记录下来方便复现问题。之所以定这些指标是因为它们全部对应 Shopify 事件里暴露出的问题UI 可换、核心稳定、协议清晰、可排查。下面直接进入实现。3. 实操复现给 Agent 用的无头架构落地3.1 环境准备与项目骨架我用的环境是 Node.js 20 TypeScript pnpm workspace。用 monorepo 的原因很简单核心包和两个头CLI、HTTP必须是独立包才能强制自己遵守“核心不依赖头”的原则。如果放在一个 package 里很容易在哪次重构中偷偷把逻辑写进 express 路由里。先初始化项目pnpm init pnpm add -w typescript types/node tsx然后创建 packages 目录每个包单独初始化。目录结构长这样packages/ core/ src/ index.ts engine.ts tool-registry.ts memory.ts package.json head-cli/ src/ index.ts package.json head-http/ src/ index.ts package.jsoncore 包是整个架构里唯一的“身体”head-cli 和 head-http 都是“头”。核心包不依赖 fastify、readline甚至不依赖任何 HTTP 库这样才能保证将来你想再加一个 Telegram Bot 头时核心一个字节都不用改。3.2 第一步定义 Agent 核心引擎无头架构里的核心引擎本质上是一个稳定的事件循环接收任务规划下一步选择工具执行工具观察结果继续规划直到得出最终答案。我先用 TypeScript 接口把这套循环描述出来// packages/core/src/engine.ts export interface AgentStep { tool: string; args: Recordstring, unknown; output: string; } export interface AgentRunOptions { task: string; sessionId: string; maxSteps?: number; } export interface AgentEngine { run(options: AgentRunOptions): Promise{ answer: string; steps: AgentStep[]; }; }接口里不出现任何 UI 概念sessionId 只负责隔离状态。这样设计的好处是CLI 头和 HTTP 头拿到同样的 result 对象想怎么展示完全自由CLI 可以直接打印 answerHTTP 可以返回 JSON将来还可以把 steps 渲染成前端时序图。为了演示完整循环我实现了一个简化引擎。真实项目里plan这一步会用 LLM 或规则决策但这里先用硬编码逻辑验证架构export class SimpleAgentEngine implements AgentEngine { async run({ task, sessionId, maxSteps 10 }) { const steps: AgentStep[] []; let currentTask task; for (let i 0; i maxSteps; i) { const selected await this.plan(currentTask); if (!selected) { return { answer: currentTask, steps }; } const output await registry.execute(selected.tool, selected.args); steps.push({ ...selected, output }); currentTask 基于观察结果${output}继续回答${task}; } return { answer: currentTask, steps }; } private async plan(task: string) { // 简化实际场景由 LLM 从 listTools 中选择 if (task.includes(计算)) { return { tool: calc, args: { expression: task.replace(计算, ) } }; } return null; } }这段代码的核心价值不是能力多强而是证明了“引擎可以脱离 UI 单独跑”。后面每次给 Agent 换“头”都在调用同一个engine.run。3.3 第二步设计工具协议Agent 的能力来自工具。无头架构里工具必须是一等公民有统一的注册和调用协议不能散落在各处。我先定义了工具类型和注册表// packages/core/src/tool-registry.ts export interface Tool { name: string; description: string; parameters: Recordstring, unknown; execute(args: any): Promisestring; } export class ToolRegistry { private tools new Mapstring, Tool(); register(tool: Tool) { this.tools.set(tool.name, tool); } list() { return [...this.tools.values()]; } async execute(name: string, args: unknown) { const tool this.tools.get(name); if (!tool) { throw new Error(Tool not found: ${name}); } return tool.execute(args); } }每个工具注册时都带上 name、description、parameters。description 是给 Agent 决策用的parameters 是用 JSON Schema 描述入参结构。这套协议和现在主流 Agent 工具调用格式非常像核心目的就是让 Agent 能根据文本描述自主决定调用哪个工具。我再注册一个计算器工具做演示registry.register({ name: calc, description: 执行简单四则运算, parameters: { type: object, properties: { expression: { type: string }, }, }, async execute(args) { // 演示用生产环境不要用 eval return String(eval(args.expression)); }, });这里必须提醒一下eval只是演示工具协议的写法真实生产环境请换成 safe-eval 或表达式解析器否则用户输入一句“恶意代码”就能把你 Agent 干掉。工具协议越明确Agent 调用越稳定。3.4 第三步接入记忆与状态记忆是无头 Agent 里最容易被低估的部分。我先定义了一个极简接口保证短期和长期记忆都能落进去// packages/core/src/memory.ts export interface Message { role: user | assistant | tool; content: string; } export interface MemoryStore { save(sessionId: string, messages: Message[]): Promisevoid; load(sessionId: string): PromiseMessage[]; }接口的关键点所有记忆都按 sessionId 隔离。这正好对应热词里“agent 记忆体系中短期、长期、永久记忆如何实现”的第一步——先锁住隔离边界再谈存储深度。短期记忆我直接用内存数组实现适合单次会话。长期记忆需要一个落地方案我写了一个最简单的 FileMemory把每个会话的对话记录存成 JSON 文件import { readFile, writeFile } from fs/promises; export class FileMemory implements MemoryStore { constructor(private basePath ./memory) {} private file(sessionId: string) { return ${this.basePath}/${sessionId}.json; } async save(sessionId: string, messages: Message[]) { await writeFile(this.file(sessionId), JSON.stringify(messages)); } async load(sessionId: string) { try { return JSON.parse(await readFile(this.file(sessionId), utf-8)); } catch { return []; } } }这个实现不依赖数据库适合原型验证。要升级成真正长期记忆时可以把 FileMemory 换成向量数据库 摘要索引但接口不用变。这就是无头架构的好处换记忆实现核心引擎不感知。3.5 第四步做两个不同的“头”核心包写完后两个头就很简单了。CLI 头用 readline 读取标准输入把用户输入交给 engine.run然后打印结果// packages/head-cli/src/index.ts import { createInterface } from readline; import { SimpleAgentEngine } from agent-core/core; const engine new SimpleAgentEngine(); const rl createInterface({ input: process.stdin, output: process.stdout }); rl.on(line, async (line) { const result await engine.run({ task: line, sessionId: local-demo, }); console.log(\n Agent 回答 ); console.log(result.answer); if (result.steps.length 0) { console.log(\n工具调用次数${result.steps.length}); } });HTTP 头用 Fastify 暴露一个 POST 接口接收 JSON 格式的 task 和 sessionId// packages/head-http/src/index.ts import Fastify from fastify; import { SimpleAgentEngine } from agent-core/core; const app Fastify(); const engine new SimpleAgentEngine(); app.post(/api/agent/run, async (req, reply) { const { task, sessionId } req.body as { task: string; sessionId: string }; const result await engine.run({ task, sessionId }); return result; }); app.listen({ port: 3000 }).then(() { console.log(HTTP head listening on :3000); });两个头的代码加起来不到 50 行共享同一个核心包。验证结果CLI 里输入“计算 12”和 HTTP 里 POST{task:计算 12,sessionId:http-demo}返回结果完全一致。核心逻辑没有动一个字节。3.6 容错、超时与可观测性无头架构能落地离不开容错。我踩过的最痛问题是工具一报错整个 Agent 就终止。所以我在工具执行层加了一个 safeExecute 包装器async function safeExecute(tool: Tool, args: unknown) { try { const output await tool.execute(args); return { ok: true, output }; } catch (e) { return { ok: false, error: { message: (e as Error).message }, }; } }关键点工具执行失败时不要直接抛异常终止 Agent而是把结构化错误返回给引擎。Agent 拿到错误后可以自行修正参数或者换一个工具。这和人遇到问题后看错误提示再重试的逻辑一致。可观测性方面我在 engine.run 的每一步都记录 tool、args、output并把 steps 完整返回给“头”。CLI 头可以直接打印HTTP 头可以返回给前端做可视化。将来接 OpenTelemetry只需要在这些 step 上补 trace 和 span核心引擎不需要改动。4. 踩坑实录Agent 无头化路上的常见问题4.1 工具调用失败agent execution terminated due to error 怎么救“agent execution terminated due to error”这类错误我见过太多次了。它几乎总是发生在同一套模式Agent 规划完以后调用工具的入参不符合工具 Schema工具里抛异常然后整个任务链断裂。排查路径其实很固定第一步看日志里 Agent 到底调了哪个工具、传了什么参数。第二步用同样的参数手动调工具确认是不是工具自身的问题。第三步检查工具 Schema 和实际入参类型是否一致比如把 string 传给了 number 字段。真正有效的修复是在工具层加统一错误包装让引擎和 Agent 都能“看见”错误并且能继续规划。我在第 3.6 节写的 safeExecute 就是这个作用。实践下来把错误信息结构化返回给模型后Agent 的自愈率提高了不少至少不会一错就死。4.2 预设列表加载失败failed to fetch 的排查路径热词里有一条“无法加载 agent 预设。client api: agentpresets/list failed: failed to fetch”这个报错的场景很典型前端在初始化时向后端请求预设列表请求发出去了但响应没回来于是所有 Agent 配置都无法加载。排查这个问题的顺序应当是先用 curl 直接请求同样的接口看后端是否正常。如果 curl 正常问题大概率在前端baseURL 写错、请求头没加认证 token、CORS 没配。如果 curl 也失败问题在后端路由没注册、服务没启动、数据库查不到预设。这套排查思路放到无头架构里有一个更彻底的解法预设列表本身就是核心配置应该由核心包维护和下发而不是让每个“头”都自己去请求一遍。CLI 头需要预设HTTP 头需要预设将来聊天机器人头也需要预设。与其让每个客户端各自维护请求逻辑不如把预设作为核心包的一部分统一注入给所有头。4.3 记忆串号多会话数据互相污染的根源与修复我最早写记忆时犯过一个低级错误MemoryStore 用了一个全局单例所有会话共享同一个数组。结果就是用户 A 问“我昨天的待办”Agent 却返回了用户 B 的记忆。这个问题的根源在于无头架构下核心引擎天然是并发多用户环境而状态没有按会话隔离。修复方法是强制所有入口显式传递 sessionId。CLI 头在本地演示时固定传一个 idHTTP 头从请求体或 Header 里提取 sessionId核心引擎内部任何记忆操作都基于这个参数。绝不允许在核心包里出现“当前会话”这种全局隐式状态。经过这次修复我总结了无头架构的状态管理铁律一切状态都应该从入参进来一切状态都应该挂在 sessionId 或 requestId 下绝不用全局变量存用户数据。4.4 别让用户等白屏Agent 响应停滞的最小解法RN 有启动白屏Agent 有“响应停滞”。模型思考要几秒工具调用要几秒如果整个过程中用户看着空界面体验和手机白屏一模一样。无头架构下解决这个问题很简单让“头”去支持流式反馈。HTTP 头可以用 SSE 把实时状态推给前端CLI 头至少可以在每次工具调用前打印一行“正在调用工具calc”。核心引擎只需要在事件循环里抛出进度事件至于头怎么展示核心不关心。这样既保证了无头核心的纯粹性又解决用户体验问题。4.5 无头 Agent 的“无限循环”陷阱Agent 在“规划→调用工具→再规划”的循环里特别容易死循环尤其是工具返回的结果不断带着新任务时。如果不加限制API 费用会跑出一个吓人的数字。我的最小修复是在 engine.run 里加 maxSteps 限制默认 10 步。同时加一个连续相同工具调用保护如果同一个工具连续调用超过 3 次直接终止并返回当前答案。这两个保护看着不起眼但在生产环境里救了我好几次。无头架构里核心引擎必须把“步数限制”当成默认安全措施而不是可选项。5. 基于这套无头架构还能往哪扩展5.1 多 Agent 协作把 Agent 变成另一个 Agent 的工具无头架构天然适合多 Agent 协作。因为每个 Agent 核心都是独立模块只要暴露一个“run”接口它就可以被注册成另一个 Agent 的工具。举个例子让主管 Agent 调用一个研究员 Agent 去查资料研究员 Agent 再调用搜索工具整个链条可以递归。我在工具注册表里就是这样做的registry.register({ name: research_agent, description: 调用另一个 Agent 做资料检索入参是 question, parameters: { type: object, properties: { question: { type: string }, }, }, async execute(args) { return otherAgent.run({ task: args.question, sessionId: sub-agent }); }, });这种“主管-执行者”模式在多 Agent 编排里非常常见。因为核心是无头的每个 Agent 不会纠缠于 UI协作起来就是纯 API 调用。5.2 工具与 Skill 的边界该怎么划Agent 领域经常聊 skill 和 agent 框架但很多人搞不清工具和 Skill 的区别。我自己理解工具是最小可执行单元Skill 是基于工具编排出来的能力封装。比如“查天气”是工具“安排出行”是 Skill它需要串起查天气、查交通、写日程三个工具再加一段 Prompt 模板。无头架构下Skill 也应该是一段纯逻辑/配置不依赖任何 UI。你可以把 Skill 定义成一个 JSON 文件提示词模板 工具调用顺序 默认参数。CLI 和 HTTP 头只是 Skill 的执行入口Skill 本身可以被单元测试直接调用。这一条对 Agent 开发新手特别重要不要一上来就写一堆界面先把 Skill 的核心跑通。5.3 记忆体系落地建议从短期到永久对应热词里“agent记忆框架以及选型”我给出一个逐步升级的路径而不是一上来就上向量库。短期记忆用内存数组只负责当前会话。长期记忆用 SQLite 或 JSONL 文件 简单关键词索引覆盖跨会话的偏好和事实。永久记忆再用向量数据库配合摘要和版本化用于知识库型任务。三个阶段可以用同一个 MemoryStore 接口承接核心引擎不感知后端存储的变化。我建议所有 Agent 项目先落到第二层跑通以后再加向量库否则会陷入“为记忆而记忆”的泥潭。5.4 Agent 安全与权限手伸得越长越危险Agent 无头化以后能力边界更清晰但安全也更容易被忽略。给 Agent 绑定工具时一定要遵守最小权限原则。比如查询数据库不要给 Agent 一个可以直接执行 SQL 的工具而是给它一个封装好的“按用户ID查订单”函数。否则提示注入攻击很容易通过用户输入控制 SQL 拼接。HTTP 头也要做鉴权至少加 API Key 校验。CLI 头虽然本地使用但也要避免工具读取敏感文件。无头架构的核心引擎是能力的集合能力越强越要严格做权限裁剪。这条我在复现前期没太在意后来模拟了一次“用户让 Agent 读 /etc/passwd”才真正重视起来。5.5 回到 Shopify 事件头可以换核心不能垮把整个复现做完以后我反过来更理解 Shopify 的选择。React Native 不是原罪原罪是“让框架绑架业务核心”。Shopify 可以把前端换成原生、换成 Flutter甚至未来换成更激进的东西但它的商品、订单、库存核心必须稳定。Agent 开发也是一样模型可以换框架可以换载体可以换但规划、记忆、工具调用这些核心能力必须以稳定的协议和独立的模块存在。我个人在这套无头架构里最大的收获不是写出了多牛的 Agent而是学会了把每个系统都问一遍如果明天把头换掉身体还能不能活Agent 开发正处在一个快速变化的阶段今天火热的框架明天可能被取代但无头架构给你留了一张保底牌。
返回列表