阅读视图

发现新文章,点击刷新页面。

Unity UI事件的拓展与应用

前言

       Unity本身的UI事件监听机制没办法传递额外的数据,而我们在游戏实际开发中经常需要传递事件的业务数据,以方便业务功能的开发。例如,我们点击一个按钮,如果这个按钮的点击事件中能传递我们的自定义数据,例如枚举,那岂不是更加有效的提高开发效率和代码的可读性。下面我们来对Unity UI事件进行一个扩展,以实现这个功能。

一、UI事件数据的设计

       Unity 原本也提供了UI事件的数据结构传递,但它不满足我们的需求。我们需要在不改变它原有的传递结构上加上我们的自定义数据。我们可以定义一个类,如UIEventData。这个类有我们自定义的数据结构,它也把原本的事件数据包含进来,响应事件时传递这个类就行。这个类的代码如下:

UIEventData.cs

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
namespace Simple.UI
{
    public class UIEventData
    {
        public PointerEventData PointerData;
        public object OtherData;
        
    }
}

二、UI事件监听器的设计

       Unity 原本提供了各种UI事件的监听,例如OnPointerDown、OnPointerUp等。核心思想是,我们不需要改变它们的监听逻辑,只需要在它的监听逻辑之后加入我们的自定义数据,然后向上传递事件即可。要实现这个功能,我们可以定义一个UIListener,它继承自EventTrigger,代码如下:

using System;
using UnityEngine;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using UnityEngine.UI;

namespace Simple.UI
{    
    /// <summary>
    /// UI事件监听器
    /// </summary>
    public class UIListener : EventTrigger
    {
        public UIEventData EventData { private set; get; }//我们自定义的事件数据
        public Action<UIEventData> OnClickDown;//自定义上向上传递的事件
        public Action<UIEventData> OnClickUp;
        public Action<UIEventData> OnClick;
        public Action<UIEventData> OnStartDrag;
        public Action<UIEventData> OnOverDrag;
        public Action<UIEventData> OnDraging;
        public Action<UIEventData> OnDroping;
        private bool _interactable = true;//是否可交互        

        //go为ui对象,如button\image等,eventData为安需要自定义的外部数据
        public static UIListener Get(GameObject go, object eventData)
        {
            UIListener listener = go.GetComponent<UIListener>();
            if (listener == null)
                listener = go.AddComponent<UIListener>();//加入监听器
            listener.EventData = new UIEventData();
            listener.EventData.OtherData = eventData;//保存自定义数据
            return listener;
        }

        public static UIListener Get(Transform t, object eventData)
        {
            return Get(t.gameObject, eventData);
        }

        public static UIListener Get(Component c, object eventData)
        {
            return Get(c.gameObject, eventData);
        }
        /// <summary>
        /// 设置是否可以交互
        /// </summary>
        /// <param name="interactable"></param>
        public void SetInteractable(bool interactable)
        {
            _interactable = interactable;
        }



        void OnDestroy()
        {
            OnClickDown = null;
            OnClickUp = null;
            OnClick = null;
            OnStartDrag = null;
            OnOverDrag = null;
            OnDraging = null;
            OnDroping = null;
            triggers.Clear();
        }
        //重写事件传递逻辑
        public override void OnPointerDown(PointerEventData eventData)
        {
            if (!_interactable)
                return;

            base.OnPointerDown(eventData);
            EventData.PointerData = eventData;//保存unity的事件数据
            OnClickDown?.Invoke(EventData);//向上传递事件         
            
        }
        
        public override void OnPointerUp(PointerEventData eventData)
        {
            if(!_interactable) return;

            base.OnPointerUp(eventData);
            EventData.PointerData = eventData;
            OnClickUp?.Invoke(EventData);            
        }
        
        public override void OnPointerClick(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnPointerClick(eventData);
            EventData.PointerData = eventData;
            OnClick?.Invoke(EventData);
        }
        
        public override void OnBeginDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnBeginDrag(eventData);
            EventData.PointerData = eventData;
            OnStartDrag?.Invoke(EventData);

            _clickDownCount++;
        }
        
        public override void OnEndDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnEndDrag(eventData);
            EventData.PointerData = eventData;
            OnOverDrag?.Invoke(EventData);         
        }
        
        public override void OnDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnDrag(eventData);
            EventData.PointerData = eventData;
            OnDraging?.Invoke(EventData);
        }
        
        //public override void OnDrop(PointerEventData eventData)
        //{
        //    base.OnDrop(eventData);
        //    EventData.PointerData = eventData;
        //    OnDraging?.Invoke(EventData);
        //}
    }
}

三、监听器的应用

       我们以一个切换游戏语言的例子来说明拓展后的事件监听器如何应用。首先我们定义一个语言枚举,如下所示:

public enum LangTypeEnum
{
    none = 0,
    English = 1,
    ChineseSimplified = 2,
    ChineseTraditional = 3,
    French = 4,
    German = 5,
    Italian = 6,
    Japanese = 7,
    Dutch = 8,
    Spanish = 9,
    Portuguese = 10,
    Hebrew = 11,
    Russia = 12,
    Danish = 13,
    Norwegian = 14,
    Finnish = 15,
    Swedish = 16,
    Hindi = 17,
    Bengali = 18,
    Turkish = 19,
    Indonesian = 20,
    Filipino = 21,
    Thai = 22,
    Malay = 23,
    Arabic = 24,
    Vietnamese = 25,
    Ukrainian = 26,
    Korean = 27,
    Czech = 28,
    Polish = 29,
    Slovak = 30,
    Slovenian = 31,
    Hungarian = 32,
    Romanian = 33,
    Greek = 34,
    Croatian = 35,
    Bulgarian = 36,

}

       然后我们在游戏的语言设置界面上,当点击对应的语言按钮时传入相关的语言枚举,以实现游戏语言的切换,代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Simple.UI;
using System;

namespace Gamelogic
{

    /// <summary>
    /// 设置界面绑定
    /// </summary>
    public class UIStageSettingsBinder : MonoBehaviour
    {
      
        private LangTypeEnum _curLang = LangTypeEnum.English;    
        private void Awake()
        {                    
            LoadLangItem();      
        }

        private void OnDestroy()
        {

        }
        private void LoadLangItem()
        {   
            var ems = Enum.GetNames(typeof(LangTypeEnum));
            //通过枚举实例化语言按键
            foreach (var e in ems)
            {
                var em = Enum.Parse<LangTypeEnum>(e);
                if (em == LangTypeEnum.none)
                    continue;
                var item = Pool.SpawnOut(ResConfig.Pre_UI_LangItem, true, 0);//语言按钮prefab
                item.SetParent(_langItemParent);                
                var igo = item.ActiveTransform;
                igo.localScale = Vector3.one;
                igo.localPosition = Vector3.zero;

                UIListener.Get(igo, em).OnClick = OnSelectedClick;//绑定事件和自定义数据
                UIListener.Get(igo, em).OnStartDrag = OnStartDrag;
                UIListener.Get(igo, em).OnDraging = OnDraging;
                UIListener.Get(igo, em).OnOverDrag = OnOverDrag;
                var binder = igo.GetComponent<UIStageLangItemBinder>();
                binder.SetName(e);
                binder.SetIcon($"flag_{e}");
            }
           
        }
       
        private void OnStartDrag(UIEventData ed)
        {
            
        }
        private void OnOverDrag(UIEventData ed)
        {
           
        }
        private void OnDraging(UIEventData ed)
        {
           
        }
        private void OnSelectedClick(UIEventData ed)
        {           
            var lt = (LangTypeEnum)ed.OtherData;         
            _curLang = lt;
          
            OnChangeClick(ed); //切换言语      
        }
 
        private void OnChangeClick(UIEventData ed)
        {
         
        }
      
    }
}

四、运行效果

       界面内的每个按钮对应着一个语言枚举,只要我们按下按钮,就可以把枚举值传递到相关业务层,对编码的友好度和效率的提升还是比较明显的。

多平台架构交互设计与方案:移动、PC、Pad的无缝响应式集成

引言

在当今多设备、跨平台的数字世界中,用户期望能够在不同的设备上流畅无缝地体验应用。无论是移动端、PC端还是Pad端,每个平台的界面和交互设计都存在不同的特点和需求。因此,如何设计一个能在多个平台间自适应并提供一致体验的架构,成为了技术开发者面临的一个重要挑战。本文将深入探讨如何在多个平台(移动、PC、Pad)之间进行架构交互设计,保证响应式设计的无缝集成,并提出解决方案,帮助开发者构建跨平台的一致体验。

1. 问题定义与背景

随着移动互联网的快速发展,应用的使用场景已经不仅仅局限于单一设备,用户越来越多地在不同设备间切换。例如,同一个用户可能在出门时使用手机浏览网页,回到办公室后则使用PC端完成更复杂的操作,甚至在平板上进行更便捷的娱乐或文档处理。这种设备间的频繁切换要求开发者提供一个无缝且一致的用户体验。

主要问题包括

  • 平台间一致性:不同设备的屏幕尺寸、输入方式(触摸、鼠标、键盘)、硬件能力等差异使得跨平台设计成为一项复杂任务。
  • 响应式设计:如何在不同屏幕尺寸下,动态调整布局和功能,确保界面元素始终易于操作且可访问。
  • 架构适配:多平台架构要求对不同平台的交互设计进行适配和优化,避免重复开发和资源浪费。
  • 性能和加载速度:跨平台应用需要保证高效的性能,无论是在高性能PC端还是资源有限的移动端,都能提供流畅的体验。

因此,如何设计一个能够支持多个平台,并在不同平台间实现无缝切换的系统架构和交互设计,成为了技术团队需要重点考虑的问题。

2. 解决方案与技术实现

为了实现移动、PC、Pad之间的无缝响应式集成,开发者需要采用适应性设计、响应式布局和统一的开发架构。以下是一些具体的解决方案与技术实现方法:

2.1 响应式设计(Responsive Design)

响应式设计的核心思想是通过CSS媒体查询(Media Query)和灵活布局,动态适配不同屏幕尺寸的设备。常用的技术包括:

  • CSS Grid和Flexbox:这两种CSS布局方式可以帮助创建灵活的网格布局,使得界面元素可以根据屏幕宽度自动调整。
  • 媒体查询(Media Queries) :根据不同的设备特性(如屏幕宽度、分辨率、方向等),动态调整页面样式。

示例代码:

/* 针对大屏PC端 */
@media (min-width: 1024px) {
  .container {
    display: flex;
    flex-direction: row;
  }
}

/* 针对平板和移动设备 */
@media (max-width: 1024px) {
  .container {
    display: block;
    width: 100%;
  }
}
2.2 组件化与跨平台框架

为了在不同平台上实现统一的用户体验,采用组件化设计和跨平台开发框架至关重要。以下是一些流行的跨平台技术:

  • React Native:适用于构建移动端和PC端应用,能够共享大部分代码。
  • Flutter:Google推出的跨平台开发框架,支持iOS、Android、Web以及桌面端的应用开发,提供高度一致的用户体验。
  • Electron:用于构建跨平台桌面应用,支持Windows、macOS和Linux。

这些框架通过组件化开发,可以减少不同平台之间的重复代码,使得维护和开发变得更加高效。

2.3 设计适配与平台差异化

尽管响应式设计和组件化开发能帮助我们在不同平台上实现一致性,但平台差异依然不可忽视。不同平台的交互方式(如触摸屏和鼠标输入)和性能需求不同,因此我们需要对每个平台的特性进行适配:

  • 移动端:触摸屏操作频繁,需要关注按钮的大小、间距以及手势操作的支持。
  • PC端:需要考虑鼠标操作、键盘快捷键、窗口大小等,界面上可以容纳更多的内容。
  • Pad端:平板设备往往介于手机和PC之间,需要更加灵活的布局,适应不同的使用场景(例如横屏或竖屏)。

为此,可以使用不同平台的特定API来进一步优化交互体验。例如:

  • 移动端,可以通过使用Touch事件优化触摸操作;
  • PC端,可以通过响应鼠标和键盘事件来增强交互。
2.4 统一的后端架构与数据同步

为了确保在不同平台之间无缝切换,统一的后端架构和数据同步至关重要。开发者可以选择RESTful API或GraphQL作为前后端通信的标准协议,确保数据能够在不同设备间实时同步。

  • RESTful API:标准的HTTP接口,适用于大多数应用场景,简单易用。
  • GraphQL:一种灵活的查询语言,可以根据客户端的需要精确获取数据,避免不必要的数据加载。

数据同步方案可以利用WebSocket或者Firebase等实时数据同步工具,实现不同平台的数据实时更新。

3. 优缺点分析与实际应用建议

3.1 优点
  • 一致性体验:通过响应式设计和跨平台框架,可以实现不同平台上用户界面的统一性,使得用户无论在何种设备上使用应用,体验都十分流畅。
  • 开发效率:组件化设计和跨平台框架减少了重复开发,提高了开发效率,尤其是在多平台并行开发时。
  • 维护简化:统一的后端架构和数据同步机制,使得多平台应用的维护变得更加简洁和高效。
3.2 缺点
  • 性能问题:某些跨平台框架(如React Native或Flutter)在某些平台上可能会面临性能瓶颈,尤其是在图形密集型应用中(如游戏、3D渲染等)。
  • 平台差异:尽管响应式设计和跨平台框架能够处理大部分情况,但某些平台差异仍需特别关注,例如移动端的触摸输入与PC端的鼠标输入之间的差异。
实际应用建议:
  • 在开发应用时,应根据目标平台的特点来选择合适的框架和技术。例如,对于需要极高性能的桌面应用,使用Electron可能会面临性能问题,可以考虑专门针对PC平台开发的技术栈。
  • 在移动端和PC端的设计上,应通过用户测试来确定最佳的布局和交互方式,以确保用户在不同设备上的操作体验始终流畅。

4. 结论

在多平台架构的交互设计中,实现无缝响应式集成对于提升用户体验至关重要。通过采用响应式设计、跨平台开发框架、组件化开发以及统一的后端架构,我们能够在多个平台之间实现一致的功能和体验。然而,如何在性能和跨平台一致性之间取得平衡,仍然是开发者面临的挑战。随着技术的不断发展,未来可能会出现更多的解决方案,使得多平台开发变得更加高效和可靠。

5. 附录与参考资料

  • 相关书籍

    • 《响应式Web设计:HTML5和CSS3实战》 - Ben Frain
    • 《深入浅出React和Redux》 - 赵乾
  • 相关技术栈

  • 在线工具

多标签页强提醒不重复打扰:从“弹框轰炸”到“共享待处理队列”的实战

场景:我在多标签页里“接力”处理紧急待办

这篇文章讨论的不是“消息列表怎么做”,而是紧急待办的强提醒体验应该如何落地。我的核心需求很明确:

  • 紧急消息必须强制弹框提醒(不能靠用户自己去小铃铛里找)
  • 弹框不能手动关闭,只能通过“去处理/已读”等业务动作逐条消解
  • 刷新后仍要继续弹:只要还有“高优先级且未处理”的消息,就必须再次弹框
  • 多标签页不重复打扰:同一时间只允许一个标签页弹;未处理的消息能跨标签页接力,不丢失 ✅

问题 1:多标签页重复强弹(“弹框轰炸”)💥

现象

  • A 中点“去处理”打开 B
  • B 打开后会立即执行轮询(而 A 里此时还有 3 条未处理)
  • 于是 B 会再次强弹:同一批剩余 3 条被重复弹出 😵

一句话总结:两个入口(WS + 初始化轮询)叠加在“多标签页”上,会让强提醒被重复触发

我认为合理的产品设计应该是什么样?🧩

我的判断标准很简单:既要“强提醒不遗漏”,也要“用户不被打断到崩溃”。

  • 同一时刻只能有一个强提醒弹框(避免轰炸)✅
  • 弹框容器支持多条消息(用户能逐条处理)✅
  • 点击“去处理”后,新标签页应该进入处理模式
    • 不再重复强弹当前未处理的那一批(否则每开一个 tab 都弹一次)✋
    • 但消息仍需保留在“小铃铛/待处理列表”里(避免漏掉)✅
  • 当“处理标签页关闭或处理结束”,系统再允许其他标签页接力弹框 ✅

解决思路

先把“是否允许弹框”这件事独立出来:
用一个全局锁控制“同一时间只有一个标签页允许弹框” 👇

flowchart LR
  M[紧急消息到达] --> L{全局锁存在?}
  L -- 是 --> Q[不弹框/仅记录]
  L -- 否 --> S[获得锁并弹框]

解决方案选择:锁放哪儿?锁归属怎么判?

要让“别的标签页不弹”很简单,但我还需要保证:当前弹框页可以继续追加新紧急消息
这就引出了一个细节:我不仅要知道“有没有锁”,还要知道“锁属于谁” 👉

我当时的选型路径是一个很典型的逐步排除法(先快后稳 👍):

  • sessionStorage:上手快,但“同标签页跳转仍共享”,A→B 会错判“我还是持锁页” ✋
  • window(自定义 key):可跨页保存,但 window 全局属性容易被别的脚本覆盖 ⚠️
  • Pinia(不持久化)与应用状态一致、可控、风险低

为什么 Pinia 不持久化

  • Pinia 的这个 key 本质是“临时归属标记”,只服务于当前运行时
  • 如果持久化,浏览器异常关闭/崩溃导致未清理,会出现锁遗留,后续可能一直不弹强提醒 😵

最终方案(问题 1)

  • localStorage:存“全局锁”本体(跨标签页共享)
  • Pinia:存“当前标签页持有的锁 key”(仅当前标签页生效)

示例代码(与实现一致):

const urgentDialogActivePrefix = 'crm.urgent_dialog_active:';

export function setUrgentDialogActive() {
  const store = useNotificationStore();
  const existingKey = findUrgentDialogActiveKey();
  if (existingKey) return existingKey;
  try {
    const key = `${urgentDialogActivePrefix}${Date.now()}`;
    localStorage.setItem(key, '1');
    store.setUrgentDialogActiveKey(key);
    return key;
  } catch {
    return null;
  }
}

export function isUrgentDialogActiveForCurrentTab() {
  const store = useNotificationStore();
  try {
    const key = store.urgentDialogActiveKey;
    if (!key) return false;
    return localStorage.getItem(key) === '1';
  } catch {
    return false;
  }
}

问题 2:关闭 A 后,B 只弹新消息,旧的 3 条“丢了”😵

现象

在问题 1 的锁机制生效后:

  • B 不会重复弹框 ✅
  • WS 的新紧急消息会继续 push 到 A 的弹框 ✅
  • 但当 A 关闭后,B 再收到新消息时,只展示新来的 1 条 ❌

本质问题:弹框是“唯一入口”,但紧急消息的“待处理状态”没有被稳定地“先存起来”。一旦持锁页关闭,下一标签页如果只基于“新来的 WS 消息”触发弹框,就容易出现“旧的未处理没带上”的错觉。

解决思路

把“消息状态”从“弹框状态”里解耦出来:
弹框只是 UI,待处理列表才是关键。

这里我后来更偏向一个更轻量的实现:队列不跨标签页持久化,而是交给“页面加载必定会执行一次的轮询”来重建——

  • 先轮询一次,把“高优先级且未处理”的消息塞进 Pinia 队列
  • 轮询成功后再连接 WS
  • 后面无论是轮询刷新还是 WS 推送,先把消息写入 Pinia 队列;能弹时一次性把队列里的都弹出来 ✅

解决方案选择:未处理队列放 localStorage 还是 Pinia?

这里的核心不是“哪个存储更强”,而是我们的事实源是什么
既然页面加载(以及后续定时)都会轮询到“高优先级且未处理”的消息,那么队列完全可以由轮询在每个标签页内重建;此时把队列写进 localStorage 反而会引入额外风险。

  • 方案 A:localStorage 存队列(跨标签页共享/持久化)
    • 优点:跨标签页天然共享;刷新/崩溃后仍可恢复
    • 代价:有空间上限(通常几 MB),队列稍大或字段稍多就可能触发 setItem 失败;还要额外设计 TTL/容量上限/清理策略,否则容易“越积越多”
  • 方案 B:Pinia 存队列(内存态,每 tab 自己维护)
    • 优点:没有 localStorage 的序列化/配额风险;状态更新更直接、可控;与“页面加载立即轮询一次”的事实源一致
    • 代价:队列不跨标签页共享,因此需要把“接力”交给轮询:持锁页关闭后,其他标签页通过轮询重建队列再弹框

我选择 Pinia 队列 + localStorage 只存锁
队列的权威来源是“轮询返回的未处理紧急消息”,而不是浏览器本地持久化;这样做能把失败面缩到最小,同时仍能满足“接力不丢”的体验目标 ✅

最终方案(问题 2):先轮询后 WS + Pinia 队列 + 正确的执行顺序

关键点不在“有没有队列”,而在“先后顺序”:

  1. 先把轮询结果入队(页面加载立刻执行一次,先拿到“历史未处理”)
  2. 轮询成功后再连接 WS(避免 WS 抢跑导致“只弹新来的”)
  3. 任何来源的紧急消息都先入队,再判断锁(不持锁也要缓存)
  4. 能弹时直接渲染队列(一次性补齐旧的 + 新的) ✅
sequenceDiagram
  participant Poll as 轮询(立即执行)
  participant WS as WebSocket
  participant Tab as 当前标签页
  participant Q as Pinia(待处理队列)
  participant Lock as localStorage(锁)
  participant UI as 强提醒弹框

  Poll->>Tab: 拉取未处理紧急消息 list
  Tab->>Q: replacePending(list) ✅
  Tab->>WS: connect() ✅
  WS->>Tab: 收到紧急消息 item
  Tab->>Q: upsertPending(item) ✅
  Tab->>Lock: isLocked?
  alt 被其他标签页持锁
    Tab-->>UI: 不弹框,仅等待
  else 可持锁/已持锁
    Tab->>Lock: setLock()
    Tab->>UI: render(Q.pendingList) ✅
  end

示例代码(与实现一致):

const store = useNotificationStore();

const maybeOpenUrgentDialog = () => {
  if (store.urgentPendingList.length === 0) return;
  if (!isUrgentDialogActiveForCurrentTab() && isUrgentDialogActive()) return;
  setUrgentDialogActive();
  setUrgentDialogItems(store.urgentPendingList);
};

const handleUrgentIncoming = (item: NotificationMineItem) => {
  store.upsertUrgentPending({ key: getUrgentNotificationKey(item), item });
  maybeOpenUrgentDialog();
};

const fetchNotifications = async () => {
  const list = await getNotificationList({ status: 0 });
  store.replaceUrgentPending(
    list
      .filter((x) => isUrgentNotification(x) && !x.isRead)
      .map((x) => ({ key: getUrgentNotificationKey(x), item: x })),
  );
  maybeOpenUrgentDialog();
  startEcho();
};

最终效果(两类问题一起解决)🙌

  • 多标签页不再重复强弹:只有一个标签页持锁展示弹框 ✅
  • 紧急消息不会“被关掉的标签页带走”:轮询重建 + Pinia 队列兜底,能接力 ✅
  • 新消息到来时会补齐历史未处理:B 会弹 3 条旧的 + 1 条新的 ✅

总结

这次问题本质上是“同一份紧急消息,在多标签页环境下如何做到不重复打扰不遗漏”:

  • 问题 1(重复弹框):用 localStorage 全局锁保证同一时刻只允许一个标签页弹框;锁归属用 Pinia 记录,避免误判
  • 问题 2(接力丢历史):把“待处理紧急消息”从弹框组件里抽出来,改为 Pinia 队列;并通过先轮询后 WS的时序,确保“历史未处理”一定先入队,再叠加 WS 的实时增量

最终效果是:紧急消息仍然强制弹框、不可手动关闭、刷新后仍可通过轮询重建继续弹,同时多标签页不会被同一批消息反复轰炸。

主Agent与多个协同子Agent的方案设计

前言

如今的大模型应用架构设计基本都是一个主Agent携带多个子Agent。

主Agent负责调度其他垂类Agent,子Agent负责单一领域的角色,属于垂直域专家。

架构上比较类似这样:

┌─────────────────────────────────────────────────────────┐
│                    主 Agent(Orchestrator)              │
│  职责:理解用户意图、分解任务、协调子 Agent、聚合结果   │
└──────────────────────┬──────────────────────────────────┘
                       │
        ┌──────────────┼──────────────┬──────────────┐
        │              │              │              │
        ▼              ▼              ▼              ▼
   ┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
   │差旅Agent│   │日程Agent│   │支付Agent│   │通知Agent│
   │(Travel)│   │(Calendar)│  │(Payment)│  │(Alert) │
   └────────┘    └────────┘    └────────┘    └────────┘
        │              │              │              │
        └──────────────┴──────────────┴──────────────┘
                       │
        ┌──────────────┼──────────────┐
        │              │              │
        ▼              ▼              ▼
   数据库           API 服务        外部服务
   (DB)          (Flights,        (Payment,
                  Hotels,          Email,
                 Trains)          SMS)

那一个基本的LLM应用框架一般怎么设计?本文基于Midwayjs来解读分析。

Agent&提示词设计

基类Agent

所有Agent都集成于该类,核心触发如下能力。

  1. 上下文管理;
  2. 大模型调用;
  3. 提示词注入;
// src/agent/base-agent.ts
import { Logger } from "@midwayjs/core"
import { LLMService } from "@/service/llm.service"

interface Message {
  role: "system" | "user" | "assistant"
  content: string
}

interface ToolCall {
  name: string
  arguments: Record<string, any>
  id?: string
}

/**
 * Agent 基类
 * 所有 Agent 都继承这个基类
 */
export abstract class BaseAgent {
  @Logger()
  logger: any

  protected llmService: LLMService
  protected conversationHistory: Message[] = []

  constructor(llmService: LLMService) {
    this.llmService = llmService
  }

  /**
   * 初始化 Agent
   * 1. 设置系统提示词
   * 2. 注入工具定义
   * 3. 初始化对话历史
   */
  protected initializeAgent(
    systemPrompt: string,
    tools: any[]
  ): void {
    this.logger.info(`[${this.getAgentName()}] 初始化 Agent`)

    // Step 1: 清空历史对话
    this.conversationHistory = []

    // Step 2: 添加系统提示词
    const enrichedSystemPrompt = this.enrichSystemPrompt(
      systemPrompt,
      tools
    )

    this.conversationHistory.push({
      role: "system",
      content: enrichedSystemPrompt,
    })

    this.logger.info(
      `[${this.getAgentName()}] Agent 初始化完成,已注入 ${tools.length} 个工具`
    )
  }

  /**
   * 增强系统提示词(注入工具定义)
   */
  private enrichSystemPrompt(systemPrompt: string, tools: any[]): string {
    const toolDescriptions = tools
      .map(
        (tool) => `
### 工具:${tool.name}
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`
      )
      .join("\n")

    return `
${systemPrompt}

## 可用的工具

${toolDescriptions}

## 工具调用格式

当你需要使用工具时,请返回以下 JSON 格式:
\`\`\`json
{
  "type": "tool_call",
  "tool_name": "工具名称",
  "arguments": {
    "参数1": "值1",
    "参数2": "值2"
  }
}
\`\`\`

重要:
1. 每次只调用一个工具
2. 工具会返回结果,你会收到 "tool_result" 角色的消息
3. 根据工具结果继续推理和决策
4. 最终向用户返回友好的文字回复
`
  }

  /**
   * 与大模型交互(核心方法)
   */
  async callLLM(userMessage: string): Promise<string> {
    this.logger.info(
      `[${this.getAgentName()}] 用户消息: ${userMessage}`
    )

    // 1. 添加用户消息到历史
    this.conversationHistory.push({
      role: "user",
      content: userMessage,
    })

    // 2. 调用大模型
    let response = await this.llmService.call({
      model: "gpt-4",
      messages: this.conversationHistory,
      temperature: 0.7,
      maxTokens: 2000,
    })

    this.logger.info(
      `[${this.getAgentName()}] 模型响应: ${response.content.substring(0, 100)}...`
    )

    // 3. 检查是否是工具调用
    let finalResponse = response.content
    let toolCalls = this.extractToolCalls(response.content)

    // 4. 如果有工具调用,递归执行直到没有工具调用
    while (toolCalls.length > 0) {
      this.logger.info(
        `[${this.getAgentName()}] 检测到工具调用: ${toolCalls.map((t) => t.name).join(", ")}`
      )

      // 添加助手的响应到历史
      this.conversationHistory.push({
        role: "assistant",
        content: response.content,
      })

      // 执行所有工具调用
      const toolResults = await Promise.all(
        toolCalls.map((call) =>
          this.executeTool(call.name, call.arguments)
        )
      )

      // 5. 将工具结果添加到历史
      const toolResultMessage = toolResults
        .map(
          (result, index) => `
[工具结果 ${index + 1}]
工具:${toolCalls[index].name}
参数:${JSON.stringify(toolCalls[index].arguments)}
结果:${JSON.stringify(result, null, 2)}
`
        )
        .join("\n")

      this.conversationHistory.push({
        role: "user",
        content: `工具执行结果:\n${toolResultMessage}`,
      })

      this.logger.info(
        `[${this.getAgentName()}] 工具执行完成,继续推理...`
      )

      // 6. 再次调用大模型,让它基于工具结果继续推理
      response = await this.llmService.call({
        model: "gpt-4",
        messages: this.conversationHistory,
        temperature: 0.7,
        maxTokens: 2000,
      })

      this.logger.info(
        `[${this.getAgentName()}] 后续模型响应: ${response.content.substring(0, 100)}...`
      )

      // 7. 再次检查是否有工具调用
      toolCalls = this.extractToolCalls(response.content)
      finalResponse = response.content
    }

    // 8. 添加最终回复到历史
    this.conversationHistory.push({
      role: "assistant",
      content: finalResponse,
    })

    return finalResponse
  }

  /**
   * 提取工具调用(从模型响应中)
   */
  private extractToolCalls(content: string): ToolCall[] {
    const toolCalls: ToolCall[] = []

    // 匹配 JSON 格式的工具调用
    const jsonMatches = content.match(/```json\n([\s\S]*?)\n```/g)

    if (jsonMatches) {
      jsonMatches.forEach((match) => {
        try {
          const json = match.replace(/```json\n/g, "").replace(/\n```/g, "")
          const parsed = JSON.parse(json)

          if (parsed.type === "tool_call") {
            toolCalls.push({
              name: parsed.tool_name,
              arguments: parsed.arguments,
            })
          }
        } catch (error) {
          this.logger.warn(`[${this.getAgentName()}] 无法解析 JSON: ${match}`)
        }
      })
    }

    return toolCalls
  }

  /**
   * 执行工具(由子类实现)
   */
  protected abstract executeTool(
    toolName: string,
    arguments: Record<string, any>
  ): Promise<any>

  /**
   * 获取 Agent 名称
   */
  protected abstract getAgentName(): string
}

工具定义&设计

工具定义核心是基于约定式的配置体,来提供给大模型。

这些工具可以是mcp,可以是function call,在工具中增加type即可扩展。

// src/tools/travel-tools.ts

/**
 * 差旅工具定义
 * 这些工具会被注入到 Agent 的提示词中
 */
export const TRAVEL_TOOLS = [
  {
    name: "search_flights",
    description: "搜索机票,返回可用的航班列表",
    parameters: {
      type: "object",
      properties: {
        from: {
          type: "string",
          description: "出发城市(如:北京、上海)",
        },
        to: {
          type: "string",
          description: "目的城市",
        },
        date: {
          type: "string",
          description: "出发日期(格式:YYYY-MM-DD)",
        },
        return_date: {
          type: "string",
          description: "返回日期(可选,格式:YYYY-MM-DD)",
        },
      },
      required: ["from", "to", "date"],
    },
  },
  {
    name: "search_hotels",
    description: "搜索酒店,返回可用的酒店列表",
    parameters: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "目的城市",
        },
        check_in: {
          type: "string",
          description: "入住日期(格式:YYYY-MM-DD)",
        },
        check_out: {
          type: "string",
          description: "退房日期(格式:YYYY-MM-DD)",
        },
        max_price: {
          type: "number",
          description: "最高价格(可选,单位:元)",
        },
      },
      required: ["city", "check_in", "check_out"],
    },
  },
  {
    name: "book_trip",
    description: "预订机票和酒店,返回订单号",
    parameters: {
      type: "object",
      properties: {
        flight_id: {
          type: "string",
          description: "航班 ID",
        },
        hotel_id: {
          type: "string",
          description: "酒店 ID",
        },
        passengers: {
          type: "number",
          description: "乘客人数",
        },
      },
      required: ["flight_id", "hotel_id"],
    },
  },
  {
    name: "get_trip_details",
    description: "获取已预订差旅的详细信息",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
      },
      required: ["trip_id"],
    },
  },
  {
    name: "cancel_trip",
    description: "取消已预订的差旅",
    parameters: {
      type: "object",
      properties: {
        trip_id: {
          type: "string",
          description: "订单号",
        },
        reason: {
          type: "string",
          description: "取消原因(可选)",
        },
      },
      required: ["trip_id"],
    },
  },
]

export const CALENDAR_TOOLS = [
  {
    name: "add_calendar_event",
    description: "添加日历事件",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "事件标题",
        },
        start_date: {
          type: "string",
          description: "开始时间(格式:YYYY-MM-DD HH:mm)",
        },
        end_date: {
          type: "string",
          description: "结束时间(格式:YYYY-MM-DD HH:mm)",
        },
        description: {
          type: "string",
          description: "事件描述",
        },
      },
      required: ["title", "start_date", "end_date"],
    },
  },
  {
    name: "get_calendar_events",
    description: "查询特定日期的日历事件",
    parameters: {
      type: "object",
      properties: {
        date: {
          type: "string",
          description: "查询日期(格式:YYYY-MM-DD)",
        },
      },
      required: ["date"],
    },
  },
]

export const PAYMENT_TOOLS = [
  {
    name: "process_payment",
    description: "处理支付请求",
    parameters: {
      type: "object",
      properties: {
        order_id: {
          type: "string",
          description: "订单号",
        },
        amount: {
          type: "number",
          description: "金额(单位:元)",
        },
        payment_method: {
          type: "string",
          enum: ["credit_card", "debit_card", "wechat", "alipay"],
          description: "支付方式",
        },
      },
      required: ["order_id", "amount", "payment_method"],
    },
  },
]

export const ALERT_TOOLS = [
  {
    name: "send_notification",
    description: "发送通知给用户",
    parameters: {
      type: "object",
      properties: {
        title: {
          type: "string",
          description: "通知标题",
        },
        content: {
          type: "string",
          description: "通知内容",
        },
        channels: {
          type: "array",
          items: { type: "string", enum: ["email", "sms", "app"] },
          description: "通知渠道",
        },
      },
      required: ["title", "content", "channels"],
    },
  },
]

MCP设计

Agent基于多个Mcp能力的提供从而实现更垂直的领域能力。

因此Mcp也可以单独设计出来。

// src/mcp/types.ts

/**
 * MCP 工具定义
 */
export interface MCPTool {
  name: string
  description: string
  inputSchema: {
    type: "object"
    properties: Record<string, any>
    required: string[]
  }
}

/**
 * MCP 资源定义
 */
export interface MCPResource {
  uri: string
  name: string
  description: string
  mimeType: string
  contents: string
}

/**
 * MCP 提示词定义
 */
export interface MCPPrompt {
  name: string
  description: string
  arguments?: Array<{
    name: string
    description: string
    required?: boolean
  }>
}

/**
 * MCP 工具调用请求
 */
export interface MCPToolCallRequest {
  toolName: string
  arguments: Record<string, any>
}

/**
 * MCP 工具执行结果
 */
export interface MCPToolResult {
  success: boolean
  data?: any
  error?: string
}

/**
 * MCP 服务器接口
 */
export interface IMCPServer {
  // 获取服务器信息
  getServerInfo(): Promise<{
    name: string
    version: string
    capabilities: string[]
  }>

  // 列出所有可用工具
  listTools(): Promise<MCPTool[]>

  // 执行工具
  callTool(request: MCPToolCallRequest): Promise<MCPToolResult>

  // 列出所有可用资源
  listResources(): Promise<MCPResource[]>

  // 获取资源内容
  getResource(uri: string): Promise<MCPResource>

  // 列出所有可用提示词
  listPrompts(): Promise<MCPPrompt[]>

  // 获取提示词内容
  getPrompt(name: string, arguments?: Record<string, string>): Promise<string>
}

有了AgentMcp,本质上完整的一次自然语言对话 -> 反馈的系统流转图就很清晰了。

基于这套框架来扩展即可。

一次完整对话到反馈的时序图大概是这样:

用户                主Agent              子Agent           MCP服务器         LLM模型          数据库
 │                   │                   │                 │                │                │
 │ 用户请求:         │                   │                 │                │                │
 │ "帮我订一张      │                   │                 │                │                │
 │  明天北京到      │                   │                 │                │                │
 │  上海的机票      │                   │                 │                │                │
 │  和酒店"        │                   │                 │                │                │
 │──────────────────>│                   │                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 1. 初始化对话      │                 │                │                │
 │                   │    构建系统提示词  │                 │                │                │
 │                   │────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 2. 请求可用工具列表│                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │                │
 │                   │ 3. 返回工具列表    │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    (search_flights, search_hotels,   │                │                │
 │                   │     book_trip, etc.)                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 4. 获取提示词模板  │                 │                │                │
 │                   │──────────────────────────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │ 5. 返回提示词      │                 │                │                │
 │                   │<──────────────────────────────────────│                │                │
 │                   │   (booking_recommendation等)         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 6. 构建系统消息    │                 │                │                │
 │                   │    (系统提示词+工具定义+提示词)      │                │                │
 │                   │    users消息="用户请求内容"         │                │                │
 │                   │                   │                 │                │                │
 │                   │ 7. 调用 LLM        │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析意图       │
 │                   │                   │                 │                │  (BOOK_TRIP)    │
 │                   │                   │                 │                │  提取参数       │
 │                   │                   │                 │                │  (from, to,date)│
 │                   │                   │                 │                │  生成工具调用   │
 │                   │                   │                 │                │                │
 │                   │ 8. LLM 响应        │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_flights",  │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "from": "北京",                │                │                │
 │                   │        "to": "上海",                  │                │                │
 │                   │        "date": "明天"                 │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 9. 检测到工具调用, │                 │                │                │
 │                   │    路由到子Agent   │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 10. 子Agent     │                │                │
 │                   │                   │     处理工具调用 │                │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 11. Travel MCP  │                │                │
 │                   │                   │     执行         │                │                │
 │                   │                   │     search_flights│               │                │
 │                   │                   │                 │ 查询数据库     │                │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回机票列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 12. 返回工具结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    [                                │                │                │ │                   │      {                               │                │                │ │                   │        "id": "CA123",                │                │                │ │                   │        "airline": "国航",             │                │                │ │                   │        "departure": "10:00",         │                │                │ │                   │        "price": 1200                 │                │                │ │                   │      },                              │                │                │ │                   │      ...                             │                │                │ │                   │    ]                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 13. 添加工具结果   │                 │                │                │
 │                   │     到对话历史     │                 │                │                │
 │                   │     再次调用 LLM   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析机票      │
 │                   │                   │                 │                │  生成下一个工具│
 │                   │                   │                 │                │  调用:         │
 │                   │                   │                 │                │  search_hotels │
 │                   │                   │                 │                │                │
 │                   │ 14. LLM 响应(第2次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "search_hotels",   │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "city": "上海",                │                │                │
 │                   │        "check_in": "明天",            │                │                │
 │                   │        "check_out": "后天"            │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 15. 再次路由到子Agent│                │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 16. 执行        │                │                │
 │                   │                   │     search_hotels│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 查询酒店       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回酒店列表   │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 17. 返回酒店结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 18. 再次调用 LLM   │                 │                │                │
 │                   │     (决定下一步)   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │  分析酒店      │
 │                   │                   │                 │                │  推荐最佳套餐  │
 │                   │                   │                 │                │  生成工具调用: │
 │                   │                   │                 │                │  book_trip     │
 │                   │                   │                 │                │                │
 │                   │ 19. LLM 响应(第3次)│                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    {                                 │                │                │
 │                   │      "type": "tool_call",            │                │                │
 │                   │      "tool_name": "book_trip",       │                │                │
 │                   │      "arguments": {                  │                │                │
 │                   │        "flight_id": "CA123",         │                │                │
 │                   │        "hotel_id": "SH001"           │                │                │
 │                   │      }                               │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 20. 路由到子Agent  │                 │                │                │
 │                   │ (预订差旅)         │                 │                │                │
 │                   │────────────────────>│                │                │                │
 │                   │                   │                 │                │                │
 │                   │                   │ 21. 执行book_trip│               │                │
 │                   │                   │──────────────────>│                │                │
 │                   │                   │                 │                │ 创建订单       │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回订单号     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 22. 返回预订结果   │                 │                │                │
 │                   │<────────────────────                 │                │                │
 │                   │    {                                 │                │                │
 │                   │      "trip_id": "TRIP_001",          │                │                │
 │                   │      "status": "confirmed",          │                │                │
 │                   │      "total_cost": 3000              │                │                │
 │                   │    }                                 │                │                │
 │                   │                   │                 │                │                │
 │                   │ 23. 调用Calendar MCP│                │                │                │
 │                   │     添加日程        │                │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 添加日历事件   │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回事件ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 24. 调用Payment MCP│                 │                │                │
 │                   │     处理支付        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 创建支付单     │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │                   │                 │ 返回交易ID     │                │
 │                   │                   │                 │<────────────────────────────────│
 │                   │                   │                 │                │                │
 │                   │ 25. 调用Alert MCP  │                 │                │                │
 │                   │     发送通知        │                 │                │                │
 │                   │────────────────────────────────────────────────────────>│               │
 │                   │                   │                 │                │ 记录通知        │
 │                   │                   │                 │────────────────────────────────>│
 │                   │                   │                 │                │                │
 │                   │ 26. 最后调用 LLM   │                 │                │                │
 │                   │     生成友好回复   │                 │                │                │
 │                   │──────────────────────────────────────────────────────>│                │
 │                   │                   │                 │                │ 总结整个过程    │
 │                   │                   │                 │                │ 生成用户友好    │
 │                   │                   │                 │                │ 的文字回复      │
 │                   │                   │                 │                │                │
 │                   │ 27. LLM 最终响应   │                 │                │                │
 │                   │<──────────────────────────────────────────────────────│                │
 │                   │    "好的,已为您预订了从北京       │                │                │
 │                   │     到上海的差旅。您的订单号是    │                │                │
 │                   │     TRIP_001,总费用3000元。     │                │                │
 │                   │     已添加到您的日程,并发送

本质上一句话总结:对话发起后,主Agent构建基础提示词进行首轮行为分析后,然后按需注入子Agent来递归/循环完成一轮对话。

结尾

如上就非常简单直观的结合代码,讲解了现在LLM大模型应用的核心架构和角色拆解。

希望对大家有所帮助。

MCP、Agent、大模型应用架构解读

前言

随着大语言模型(LLM)的快速发展,如何让 AI 能够有效地与外部世界交互,已成为 AI 应用开发的核心课题。Anthropic 推出的 MCP(Model Context Protocol)、智能代理(Agent)和大模型应用三者的结合,形成了一套完整的 AI 系统架构。

接下来,我们深入解读这三个核心概念及其相互关系。


一、3个核心概念的定义

1.1 大模型应用(AI Application)

大模型应用是整个系统的最外层容器。它包括:

  • 应用程序框架和生命周期管理
  • 用户交互界面(CLI、Web、API等)
  • 系统配置和资源管理
  • 外部集成(数据库、监控等)
大模型应用
  ├─ 启动应用
  ├─ 管理配置
  ├─ 处理用户输入
  ├─ 返回处理结果
  └─ 关闭应用

1.2 Agent(智能代理)

Agent 是大模型应用的大脑和执行引擎。它的职责是:

  • 理解用户意图(通过大模型)
  • 规划执行步骤
  • 决定调用什么工具
  • 处理工具执行结果
  • 持续优化和迭代

Agent 的核心价值在于将大模型的推理能力与外部工具执行能力结合。

1.3 MCP(Model Context Protocol)

MCP 是一个开放的通信协议规范。它定义了:

  • 工具的统一调用接口
  • 消息的标准格式(JSON-RPC 2.0)
  • 服务的发现和注册机制
  • 错误处理规范

MCP 的核心价值在于解耦工具调用的复杂性,实现工具即插即用。


二、三者的包含关系

┌──────────────────────────────────────────────────┐
│                 大模型应用                        │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │              Agent                         │ │
│  │                                            │ │
│  │  ├─ 初始化 MCP (建立连接、获取工具)       │ │
│  │  ├─ 与大模型交互 (发送提示词、接收响应)  │ │
│  │  ├─ 解析大模型输出 (识别工具调用)        │ │
│  │  ├─ 通过 MCP 调用工具 (执行具体任务)     │ │
│  │  ├─ 处理工具结果 (反馈给大模型)          │ │
│  │  └─ 循环迭代 (直到任务完成)              │ │
│  │                                            │ │
│  │         ◄──────────────────────►          │ │
│  │            MCP (工具协议)                  │ │
│  │         ◄──────────────────────►          │ │
│  │                                            │ │
│  └────────────────────────────────────────────┘ │
│                                                  │
│  用户输入  ──►  应用处理  ──►  用户输出        │
│                                                  │
└──────────────────────────────────────────────────┘

三、工作流程详解

3.1 初始化阶段

第一步:读取配置文件(mcp.json)
  ├─ 检查有哪些 MCP Server
  ├─ 验证配置的合法性
  └─ 记录工具来源信息

第二步:连接所有 MCP Server
  ├─ 为每个 Server 创建 MCP Client
  ├─ 建立传输连接(stdio/HTTP/WebSocket)
  ├─ 发送 initialize 信息握手
  └─ 获取 Server 能力信息

第三步:获取所有工具列表
  ├─ 从每个 Server 调用 listTools()
  ├─ 收集返回的工具定义
  ├─ 合并工具列表并检查冲突
  └─ 标记每个工具来自哪个 Server

第四步:准备就绪
  └─ Agent 获得完整的工具清单,可以开始工作

代码示例:

class AIApplication {
  private agent: Agent
  
  async initialize() {
    // Agent 初始化
    this.agent = new Agent("mcp.json")
    await this.agent.initialize()
    
    console.log("✓ 应用初始化完成")
    console.log(`✓ 可用工具数: ${this.agent.toolCount}`)
  }
}

3.2 处理请求阶段

当用户输入一个请求时,完整的处理流程如下:

用户输入: "帮我计算 (10 + 5) * 2 的结果"
  │
  ▼
┌─────────────────────────────────────────┐
│  Agent 第一步:准备提示词                 │
│  ├─ 获取当前的工具列表                   │
│  ├─ 组织成 Claude 能理解的格式          │
│  └─ 加入用户的原始请求                   │
└──────────────┬──────────────────────────┘
               │
  ┌────────────▼─────────────┐
  │  Claude API              │
  │  (处理用户请求)          │
  │  ├─ 理解用户意图         │
  │  ├─ 规划执行步骤         │
  │  └─ 决定调用哪些工具     │
  │                          │
  │  Claude 响应:            │
  │  "我需要先调用 add(10,5)"│
  └────────────┬─────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第二步:处理工具调用请求     │
  │  ├─ 解析 Claude 的响应             │
  │  ├─ 识别出要调用 "add" 工具        │
  │  ├─ 找到 "add" 来自哪个 Server    │
  │  └─ 获取该 Server 的 MCP Client    │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第三步:通过 MCP 调用工具    │
  │  ├─ 构建标准化的 RPC 请求          │
  │  ├─ 调用: client.callTool("add",   │
  │  │         {a: 10, b: 5})         │
  │  └─ 等待工具执行完毕               │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  MCP Server (实际执行工具)         │
  │  ├─ 接收 RPC 请求                  │
  │  ├─ 执行: 10 + 5 = 15             │
  │  └─ 返回结果: {result: 15}        │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第四步:反馈给 Claude        │
  │  ├─ 把结果添加到对话历史           │
  │  ├─ "add(10, 5) 的结果是 15"      │
  │  └─ 重新调用 Claude               │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Claude 继续推理                  │
  │  ├─ 看到了第一步的结果             │
  │  ├─ 继续规划下一步                 │
  │  └─ "现在我需要调用 multiply(15,2)"│
  └────────────┬──────────────────────┘
               │
  (重复步骤 2-4 直到 Claude 说完成)
               │
  ┌────────────▼──────────────────────┐
  │  Claude 最终响应                   │
  │  ├─ stop_reason = "end_turn"      │
  │  ├─ content = "答案是 30"         │
  │  └─ Agent 停止循环                 │
  └────────────┬──────────────────────┘
               │
               ▼
        返回用户: "答案是 30"

3.3 循环机制的关键

Agent 的循环处理是理解整个架构的关键:

async process(userInput: string): Promise<string> {
  let messages = [{ role: "user", content: userInput }]
  
  for (let iteration = 0; iteration < maxIterations; iteration++) {
    // 1. 调用 Claude
    const response = await claude.messages.create({
      messages,
      tools: this.tools  // 传递所有可用工具
    })
    
    // 2. 添加 Claude 的响应到历史
    messages.push({ role: "assistant", content: response.content })
    
    // 3. 检查 Claude 是否完成
    if (response.stop_reason === "end_turn") {
      // Claude 完成了,返回最终答案
      const textBlock = response.content.find(b => b.type === "text")
      return textBlock.text
    }
    
    // 4. Claude 要求调用工具
    if (response.stop_reason === "tool_use") {

      const toolResults = [ ]

      
      for (const block of response.content) {
        if (block.type === "tool_use") {
          // 通过 MCP 调用工具
          const result = await this.callToolViaMCP(
            block.name,
            block.input
          )
          
          toolResults.push({
            type: "tool_result",
            tool_use_id: block.id,
            content: JSON.stringify(result)
          })
        }
      }
      
      // 5. 把工具结果添加到历史(关键!Claude 需要看到结果)
      messages.push({
        role: "user",
        content: toolResults
      })
      
      // 循环回第 1 步,Claude 基于工具结果继续推理
    }
  }
}

关键点:

  • messages 数组是"记忆",不断积累
  • 每次调用 Claude 时,都传递完整的历史
  • Claude 基于之前的工具执行结果进行下一步决策

四、MCP 的泛化调用设计

4.1 为什么需要泛化?

不泛化的方式(混乱):

// 需要为每个工具写特定代码
if (toolName === "add") {
  result = calculator.add(args.a, args.b)
} else if (toolName === "query") {
  result = database.query(args.sql)
} else if (toolName === "analyzeCode") {
  result = codeAnalyzer.analyze(args.code)
}
// ... 100+ 个 else if ...

// 问题:新增工具时要改应用代码

泛化的方式(MCP):

// 一个函数搞定所有工具
const result = await this.callToolViaMCP(toolName, args)

// 问题解决:新增工具时只需改配置

4.2 泛化的实现原理

┌──────────────────────────────────────────┐
│   统一的工具调用接口                      │
│   callTool(name: string, args: any)      │
└──────────────┬───────────────────────────┘
               │
      ┌────────┴────────┐
      │                 │
      ▼                 ▼
  ┌────────┐      ┌──────────┐
  │ Server │      │ Server   │
  │ A      │      │ B        │
  │        │      │          │
  │ add    │      │ query    │
  │ sub    │      │ insert   │
  └────────┘      └──────────┘

所有 Server 遵守相同的 MCP 规范:
  ├─ 都支持 listTools() 方法
  ├─ 都支持 callTool(name, args) 调用
  ├─ 都返回标准格式的结果
  └─ 应用无需关心 Server 差异

4.3 MCP 规范的约束

MCP 定义了统一的消息格式:

// 工具列表请求
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/list"
}

// 工具列表响应
{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "tools": [
      {
        "name": "add",
        "description": "Add two numbers",
        "inputSchema": {
          "type": "object",
          "properties": {
            "a": {"type": "number"},
            "b": {"type": "number"}
          },
          "required": ["a", "b"]
        }
      }
    ]
  }
}

// 工具调用请求
{
  "jsonrpc": "2.0",
  "id": "2",
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": {"a": 5, "b": 3}
  }
}

// 工具调用响应
{
  "jsonrpc": "2.0",
  "id": "2",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "5 + 3 = 8"
      }
    ]
  }
}

四、结尾

因此有了对这三者的核心概念的了解,其实对大模型应用开发也有了比较深入的认识了。

评论区欢迎讨论。

Fishhook原理深度剖析:从Mach-O到运行时函数Hook

前言

在iOS/macOS开发中,函数Hook是一项强大的技术,它允许我们在运行时修改函数行为,广泛应用于调试、监控、性能分析等领域。Fishhook作为Facebook开源的一个轻量级Hook框架,其设计精巧、实现简洁,是理解macOS/iOS系统动态链接机制的绝佳案例。本文将深入剖析Fishhook的工作原理,解答一系列核心问题。

一、Fishhook是什么?

Fishhook是一个基于动态链接特性的函数Hook库,专门针对macOS和iOS平台设计。它的核心思想是通过修改Mach-O文件中的符号绑定表来实现C函数的Hook。

核心特点:

  • 轻量级,仅几百行代码
  • 专注于C函数Hook
  • 基于合法的dyld API
  • 主要用于调试和监控场景

二、核心问题:Fishhook为什么只能Hook动态链接函数?

1. Mach-O文件结构回顾

要理解Fishhook的限制,首先要了解Mach-O文件结构:

Mach-O Header
Load Commands
    - LC_SEGMENT_64
    - LC_SYMTAB (符号表)
    - LC_DYSYMTAB (动态符号表)
    - LC_DYLD_INFO (绑定信息)
Data
    - __TEXT (代码段,只读)
    - __DATA (数据段,可写)
        - __la_symbol_ptr (惰性绑定指针)
        - __nl_symbol_ptr (非惰性绑定指针)
        - __got (全局偏移表)
    - __LINKEDIT (链接信息)

2. 动态链接 vs 静态链接

// 静态链接函数(无法Hook)
static void internal_function() {
    // 编译时直接嵌入二进制
    // 调用方式:call 0x100000f00 (直接地址)
}

// 动态链接函数(可以Hook)
void printf(const char *format, ...) {
    // 来自libSystem.dylib
    // 调用方式:call [__la_symbol_ptr + offset] (间接寻址)
}

关键区别: 动态链接函数通过符号指针表间接调用,Fishhook正是通过修改这个指针表中的地址来实现Hook。

3. 为什么只能Hook系统库函数?

这是一个常见的误解!Fishhook不仅可以Hook系统库函数,也可以Hook自定义动态库函数。

// 自定义动态库中的函数也可以Hook
__attribute__((visibility("default")))
void my_dynamic_function() {
    // 这个函数可以被Fishhook Hook
}

// 只要满足以下条件:
// 1. 函数来自动态库(不是静态链接)
// 2. 函数具有外部可见性(不是static)
// 3. 函数通过符号表引用

三、符号绑定机制深度解析

1. 惰性绑定(Lazy Binding) vs 非惰性绑定

惰性绑定(__la_symbol_ptr)

; 第一次调用printf时的流程
call printf    ; 实际调用桩代码

; __stubs中的桩代码
___stub_printf:
    jmp *___la_symbol_ptr[0]   ; 第一次跳转到dyld_stub_binder

; 绑定后的状态
___la_symbol_ptr[0] = 0x7fff12345678 (真实printf地址)

非惰性绑定(__nl_symbol_ptr)

// 程序启动时立即绑定的函数
// 现代iOS/macOS中,__nl_symbol_ptr已较少使用
// 取而代之的是__got(全局偏移表)

// 使用__got的示例
void *function_ptr __attribute__((section("__DATA,__got"))) = &some_function;

现代绑定机制变化

在现代 iOS/macOS 中,传统的 __nl_symbol_ptr / __la_symbol_ptr 的角色已被弱化, 编译器和 dyld 更多通过 GOT-like 的间接寻址方式以及 chained fixups 机制来完成符号绑定;因此使用时需要注意是否能正确实现hook.

# 传统方式的缺点
# 1. 重定位表可能很大
# 2. 加载时需要大量随机内存访问
# 3. 不支持新硬件特性
# 4. 安全保护有限

# Chained Fixups 的优势
# 1. 更小的二进制大小(减少30-50%重定位数据)
# 2. 更快的启动速度
# 3. 支持指针认证(PAC)
# 4. 更好的安全性和性能

// 寻址方式对比
; 传统方式(x86_64)
; 通过 __la_symbol_ptr 调用 printf
lea     rdi, [rip + format_string]
call    qword ptr [rip + ___la_symbol_ptr_printf]

; __la_symbol_ptr 节区内容:
___la_symbol_ptr_printf:
    .quad   ___dyld_stub_binder_printf

; 现代方式(arm64)
; 通过 GOT-like 间接寻址
adrp    x0, _printf@GOTPAGE     ; 获取 GOT 页
ldr     x1, [x0, _printf@GOTPAGEOFF] ; 从 GOT 加载地址
blr     x1                      ; 间接跳转

; 实际上,编译器可能生成更优化的代码
; 直接使用 PC 相对寻址,不需要显式的 GOT 节区

2. dyld_stub_binder的工作原理

当第一次调用动态链接函数时,系统如何查找真实地址?

// dyld_stub_binder的简化流程
void* dyld_stub_binder(uint32_t lazy_binding_info_offset) {
    // 1. 从__LINKEDIT获取绑定信息
    LazyBindingInfo *info = get_binding_info(offset);
    
    // 2. 提取符号名和库序号
    const char *symbol_name = info->symbol_name;
    int library_ordinal = info->library_ordinal;
    
    // 3. 在指定动态库中查找符号
    void *address = find_symbol(symbol_name, library_ordinal);
    
    // 4. 修改符号指针表
    void **symbol_ptr = info->symbol_pointer;
    *symbol_ptr = address;  // 关键步骤!
    
    // 5. 跳转到目标函数
    return address;
}

3. ASLR(地址空间布局随机化)的影响

ASLR是现代操作系统的安全特性,每次启动时系统模块的加载地址都随机变化:

// 没有ASLR:
// printf地址固定为0x7fff12345678

// 有ASLR:
// 第一次启动:printf地址为0x7fff12345678
// 第二次启动:printf地址为0x7fff56789abc
// 第三次启动:printf地址为0x7fff9abcdef0

// Mach-O通过位置无关代码支持ASLR:
// 所有外部引用都通过指针表间接访问
// 指针表地址在运行时由dyld填充

四、Fishhook的核心实现原理

1. 核心数据结构:rebinding

struct rebinding {
    const char *name;      // 要Hook的函数名
    void *replacement;     // 替换函数的地址
    void **replaced;       // 保存原始函数指针
};

2. Fishhook的工作流程

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
    // 1. 遍历所有镜像(动态库)
    for (int i = 0; i < _dyld_image_count(); i++) {
        const struct mach_header *header = _dyld_get_image_header(i);
        
        // 2. 查找__DATA段中的符号指针表
        struct segment_command *seg = find_segment(header, "__DATA");
        if (!seg) continue;
        
        // 3. 查找__la_symbol_ptr和__got节
        struct section *la_section = find_section(seg, "__la_symbol_ptr");
        struct section *got_section = find_section(seg, "__got");
        
        // 4. 处理每个符号指针
        perform_rebinding_with_section(la_section, rebindings, rebindings_nel);
        perform_rebinding_with_section(got_section, rebindings, rebindings_nel);
    }
    
    return 0;
}

3. 关键函数:查找和替换符号

static void perform_rebinding_with_section(struct section *section,
                                          struct rebinding rebindings[],
                                          size_t rebindings_nel) {
    // 1. 获取间接符号表
    uint32_t *indirect_symbol_indices = indirect_symbol_table + section->reserved1;
    
    // 2. 遍历符号指针表中的每个条目
    for (uint32_t i = 0; i < section->size / sizeof(void*); i++) {
        uint32_t symtab_index = indirect_symbol_indices[i];
        
        // 3. 在动态符号表中查找符号信息
        struct nlist_64 *symbol = &symtab[symtab_index];
        uint32_t strtab_offset = symbol->n_un.n_strx;
        const char *symbol_name = strtab + strtab_offset;
        
        // 4. 检查是否是需要Hook的函数
        for (size_t j = 0; j < rebindings_nel; j++) {
            if (strcmp(symbol_name, rebindings[j].name) == 0) {
                // 5. 找到目标!进行Hook
                void **symbol_ptr = (void**)(section->addr + i * sizeof(void*));
                
                // 保存原始函数指针
                if (rebindings[j].replaced != NULL) {
                    *rebindings[j].replaced = *symbol_ptr;
                }
                
                // 替换为新的函数地址
                *symbol_ptr = rebindings[j].replacement;
                
                break;
            }
        }
    }
}

五、实际应用场景和限制

1. 支持的函数类型

函数类型 是否支持 说明
C函数(动态库) ✅ 完全支持 Fishhook的主要目标
Objective-C方法 ❌ 不支持 使用Method Swizzling
Swift函数(@_cdecl) ✅ 条件支持 需要C接口导出
静态链接函数 ❌ 不支持 编译时直接链接
内联函数 ❌ 不支持 编译时展开

2. iOS上的特殊限制

// 在非越狱iOS设备上:
// ✅ 可以Hook自己的函数和系统公开API
// ❌ 不能Hook系统私有函数(受代码签名保护)

// 在越狱iOS设备上:
// ✅ 可以Hook所有函数

// 验证方法:
#include <fishhook.h>
#include <stdio.h>

// 可以Hook的系统公开函数
static int (*original_printf)(const char *, ...);

int hooked_printf(const char *format, ...) {
    // Hook实现
    return original_printf("[HOOKED] %s", format);
}

__attribute__((constructor))
static void setup_hook() {
    struct rebinding rebind = {"printf", hooked_printf, (void**)&original_printf};
    rebind_symbols(&rebind, 1);
}

3. 自定义动态库的Hook

// MyDynamicFramework.framework
__attribute__((visibility("default")))
void framework_function() {
    NSLog(@"Original framework function");
}

// 主工程中的Hook
static void (*original_framework_func)() = NULL;

void hooked_framework_func() {
    NSLog(@"Before framework function");
    original_framework_func();
    NSLog(@"After framework function");
}

// 安装Hook
struct rebinding rebind = {
    "framework_function",
    hooked_framework_func,
    (void**)&original_framework_func
};
rebind_symbols(&rebind, 1);

六、与Method Swizzling的对比

1. Fishhook(C函数Hook)

// 适用于C函数
void hook_c_function() {
    struct rebinding rebind = {"printf", hooked_printf, &original_printf};
    rebind_symbols(&rebind, 1);
}

2. Method Swizzling(Objective-C方法Hook)

// 适用于Objective-C方法
+ (void)hookObjectiveCMethod {
    Method original = class_getInstanceMethod([UIView class], @selector(setBackgroundColor:));
    Method swizzled = class_getInstanceMethod([self class], @selector(hooked_setBackgroundColor:));
    method_exchangeImplementations(original, swizzled);
}

3. 选择指南

场景 推荐技术
Hook C系统函数 Fishhook
Hook C自定义函数 Fishhook
Hook Objective-C方法 Method Swizzling
Hook Swift方法 Method Swizzling(通过@objc暴露)
底层系统监控 Fishhook

七、实战案例

1. 性能监控

// 监控内存分配
static void *(*original_malloc)(size_t);
static void (*original_free)(void *);

void *hooked_malloc(size_t size) {
    void *ptr = original_malloc(size);
    
    // 记录大内存分配
    if (size > 1024 * 1024) {
        printf("[MEMORY] Large malloc: %zu bytes\n", size);
    }
    
    return ptr;
}

void hooked_free(void *ptr) {
    // 可以在这里添加内存释放监控
    original_free(ptr);
}

八、常见问题解答

Q1: Fishhook能Hook静态库函数吗?

A: 取决于静态库的链接方式:

  • 如果静态库被完全复制到动态库中,可以Hook
  • 如果静态库保持外部引用,需要在链接静态库的地方Hook
  • 静态库的私有函数(static)无法Hook

Q2: 为什么OC和Swift方法不能用Fishhook Hook?

A: Objective-C和Swift的方法调用机制完全不同:

  • Objective-C使用消息发送机制(objc_msgSend)
  • Swift有自己的分发机制(虚函数表、直接调用等)
  • 它们不通过符号绑定表调用,因此Fishhook无法修改

Q3: Hook的时机是什么?

A: Fishhook可以在运行时任何时间点进行Hook,但最佳实践是:

// 1. 程序启动时(推荐)
__attribute__((constructor))
static void setup_hooks() {
    // 在main()函数之前执行
}

// 2. 运行时动态Hook
void dynamic_hook() {
    // 可以在需要时动态安装或移除Hook
}

// 3. 使用dispatch_once保证只Hook一次
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
    // 安装Hook
});

Q4: 在MachOView中为什么看不到__nl_symbol_ptr?

A: 在现代iOS/macOS系统中:

  • __nl_symbol_ptr已被__got(全局偏移表)替代
  • 所有需要立即绑定的符号现在都放在__got
  • 这是为了更好的位置无关代码支持和性能优化

九、Fishhook的局限性

1. 技术限制

  • 只能Hook通过符号表调用的函数
  • 无法Hook内联函数和静态链接函数
  • 对C++函数支持有限(名称重整问题)
  • 线程安全问题需要开发者自己处理

2. 平台限制

  • iOS非越狱设备受限较多
  • 某些系统函数受代码签名保护
  • 不同iOS版本可能有行为差异

3. 性能考虑

  • 第一次Hook需要遍历符号表,有性能开销
  • 频繁的动态Hook/Unhook可能影响性能
  • 需要考虑多线程环境下的安全性

十、未来展望

1. 替代方案

// 1. Dobby(原DobbyHook)
// 支持inline hook,更强大
#include <dobby.h>
DobbyHook((void *)printf, (void *)hooked_printf, (void **)&original_printf);

// 2. substrate(越狱设备)
// iOS越狱环境下的完整Hook框架
MSHookFunction((void *)printf, (void *)hooked_printf, (void **)&original_printf);

2. 发展趋势

  • Apple正在加强系统安全性
  • 静态分析和运行时保护机制增强
  • 对动态代码修改的限制可能增加
  • 开发者需要更多关注官方提供的调试和监控API

结语

Fishhook通过精巧地利用Mach-O文件的动态链接机制,实现了轻量级的函数Hook功能。虽然它有一定的局限性,但仍然是理解macOS/iOS系统底层机制的绝佳案例,也是许多调试和监控工具的基础。

通过本文的详细解析,我们希望读者能够:

  1. 深入理解Mach-O文件结构和动态链接机制
  2. 掌握Fishhook的工作原理和使用方法
  3. 了解不同Hook技术的适用场景
  4. 在实际开发中正确应用Hook技术

记住,强大的工具也意味着重大的责任。Hook技术应该用于正当的调试、监控和优化目的,而不是破坏系统安全或侵犯用户隐私。


参考资源:

iOS网络层工程范式迁移

引言

生活并非直线前进,而是在一次又一次的循环中向前。随着项目的更迭与技术栈的变化, 又一次站在了这个熟悉的网络层设计问题前——这已经是第几次,我大抵也记不太清了。有趣的是,问题几乎未曾改变,但在不同的技术背景与开发阶段下,循环的终点却彼此并不矛盾。为什么网络层总在被重构? 我反复更换的,究竟是网络请求库,还是对“网络层”这一概念本身的理解?当开发生产力不断提高,网络层的意义,是否也在随之发生变化?

回答这些问题,或许需要暂时抽离当下的技术语境,逆拨时间的指针,回到网络层第一次成为“工程问题”的年代。

网络层的第一次工程化

在 iOS 开发的早期,我们并没有一套足够简洁且稳定的 API 来完成一次 HTTP 请求。开发者需要直接面对 NSURLConnection 及其 delegate 回调,手动处理线程切换、状态维护、错误分支与生命周期管理。网络请求往往散落在各个业务代码中,高度依赖个人经验以及项目本身的成熟程度。

也正是在这一阶段,网络层开始显露出最初的工程问题: 如何将网络请求从零散的实现中抽离出来,使其具备一定的模块化与可复用性。

秩序的建立

AFNetworking 并不是第一个网络封装,但它是第一个被大规模接受为工程基础设施的解决方案。它所做的事情,在今天看来并不复杂:

  • 将零散的 delegate 回调收敛为 block;
  • 将请求调度交由 operation queue 统一管理;
  • 提供一致的序列化、状态与网络可达性支持。

AFNetworking 并没有试图重新定义网络的抽象边界,它解决的是一个更朴素的问题:如何统一 HTTP 请求的工程流程。从这个角度看,它的历史地位并不来自于 API 的优雅,而在于完成了一件更基础的事情——让网络请求第一次脱离“个人技巧”,进入“团队工程”的范畴。

当网络请求不再是一种高风险操作,而成为可以被稳定复用的工程能力之后,新的问题也随之浮现: 在秩序建立之后,网络层是否还需要继续演进?

语言变了,其他什么都没变

随着 Swift 的出现,iOS 开发迎来了第一次明显的语言层级跃迁。更安全的类型系统、更清晰的控制流,以及对错误处理与并发模型的重新思考,都让开发者对“代码结构”本身产生了新的期待。然而,在 iOS 的发展历程中,语言层面的变化往往具有明显的滞后性。

在相当长的一段时间里,Swift 项目仍然沿用着 AFNetworking 所建立的工程范式:请求依然被视作一次次独立的操作,生命周期、错误分支与调度逻辑分散在各处。Alamofire 也正是在这样的背景下出现的——在它的早期阶段,它所承担的角色并不是重新定义网络层,而是提供一个 Swift 版本的 AFNetworking

这并非缺乏野心,而是现实所迫。Swift 在早期版本中经历了频繁而剧烈的 API 变动,从语言特性到标准库,再到与 Foundation 的桥接关系,都在不断重写之中。这种不稳定性,对于试图迁移或学习新语言的开发者而言,无异于一盆又一盆的冷水。

也正因如此,在那个 Swift 生态尚未成熟的阶段,稳定性本身,才是最重要的工程价值

范式二次迁移

随着 Swift 生态逐渐稳定,网络层的关注点也开始发生转移:它不再只是关心“是否能够稳定工作”,而是进一步走向“流程是否足够清晰、是否具备工程可控性”。Alamofire 正是在这一时期不断演进,并主动吸收了来自其他语言与平台的工程经验。它逐渐不再只是一个请求封装库,而是开始将一次网络请求视作一个具有明确生命周期的工程流程。

SessionRequestInterceptorRetrier 等概念的引入,使得请求在进入与离开网络层时,拥有了可插拔、可观察、可扩展的处理节点。网络请求不再是一次孤立的调用,而成为一条可以被组合与调度的处理管线。也正是在这一阶段,Alamofire 从一套工程工具,演变为 iOS 网络开发中的事实标准。当人们谈论网络层时,几乎不需要再解释背景——Alamofire 本身,已经成为了那个时代 iOS 网络工程的默认前提。

再次抽象

随着 RxSwift、Combine 等声明式与函数式思想逐渐在 iOS 生态中流行,开发者开始尝试以更“描述性”的方式来组织代码结构。相比命令式地拼装请求参数,人们更希望先回答一个问题:系统中究竟存在哪些 API,它们的形态是什么?

正是在这样的背景下,Moya 在已经趋于完善的网络层实现之上,引入了一套基于 Swift 类型系统的 DSL(Domain-Specific Language)。通过 enum、case 与 protocol 的组合,Moya 尝试将网络接口本身建模为一种结构化、可推导的描述,而不再仅仅是零散的请求构造逻辑。

这种 DSL 的价值,并不在于提升网络请求的执行能力——这部分早已由 Alamofire 等底层实现所解决——而在于为接口组织、Mock 与测试提供了一种更具约束性的表达方式。网络层之上,第一次出现了“以接口为中心”的组织视角。

然而,抽象的上移并不意味着复杂度的消解。相反,当流程、接口与描述层不断叠加,网络层开始承载越来越多本不属于它的概念与责任。这种堆叠式的演进,也悄然将网络层推向了一个临界点——下一次变化,或许不再来自抽象本身。

控制权回归

真正的分水岭,并不是某一个网络库的发布,而是语言本身发生了改变。

随着 Swift Concurrency 的引入,异步执行、取消、错误传播与生命周期管理被统一纳入语言语义之中。在 Swift Concurrency 之前,即使不依赖任何第三方库,一个最基础的网络请求,也需要显式处理回调、线程、错误、生命周期等问题:

let request = URLRequest(url: url)

URLSession.shared.dataTask(with: request) { data, response, error in
    if let error = error {
        DispatchQueue.main.async {
            self.state = .error(error)
        }
        return
    }

    guard
        let data = data,
        let response = response as? HTTPURLResponse,
        200..<300 ~= response.statusCode
    else {
        DispatchQueue.main.async {
            self.state = .error(NetworkError.invalidResponse)
        }
        return
    }

    do {
        let user = try JSONDecoder().decode(User.self, from: data)
        DispatchQueue.main.async {
            self.state = .loaded(user)
        }
    } catch {
        DispatchQueue.main.async {
            self.state = .error(error)
        }
    }
}.resume()

Swift Concurrency 语境下,同样的请求可以被表达为

let request = URLRequest(url: url)

do {
    let (data, response) = try await URLSession.shared.data(for: request)

    guard
        let response = response as? HTTPURLResponse,
        200..<300 ~= response.statusCode
    else {
        throw NetworkError.invalidResponse
    }

    let user = try JSONDecoder().decode(User.self, from: data)
    state = .loaded(user)
} catch {
    state = .error(error)
}

从表面上看,变化只是几十行代码变成了几行。但真正发生转变的是:

  • 执行顺序由语言控制,而非回调嵌套
  • 错误传播通过 throw 统一语义
  • 取消由 Task 作为语言级能力管理
  • 生命周期首次成为编译器可理解的结构

旧世界的终章

面对这一轮范式转变,Alamofire 并未停滞不前。相反,它持续尝试适配新的语言能力,引入 async/await 接口,与 Swift Concurrency 接轨。然而,作为一个诞生于前并发时代的工程体系,Alamofire 不可避免地背负着历史结构的惯性:其核心模型并非以 Sendable 为前提构建,内部仍存在大量可变状态;部分 API 在新的并发语境下显得不够“语言化”,更像是一层面向过渡期的兼容包装。这并非能力不足,而是时代断层所留下的工程痕迹。值得注意的是,Alamofire 并未因此止步。只要它仍在持续演进,仍在尝试靠近语言本身的表达边界,那么它的故事就尚未结束。甚至可以设想,在未来某一个关键版本节点,当工程结构真正与语言范式完成对齐,也许 6.x 之后的 Alamofire,仍存在“王者归来”的可能。

相比之下,Moya 则呈现出另一种截然不同的轨迹。它的核心价值建立在一套以 Endpoint 为中心的 DSL 之上。然而,随着 Swift 5 之后类型系统、协议与泛型能力的成熟,以及 Swift Concurrency 的到来,这套 DSL 却并未随语言一同演化。长期缺乏对 async/await 的原生支持,使得 Moya 在新的并发模型下逐渐失去立足点;而对既有 DSL 结构的高度依赖,也限制了它吸收语言层新能力的空间。当抽象停止生长,它便不再是助力,而开始成为负担。时至今日,Moya 已逐渐淡出主流讨论视野,这并非因为它曾经的设计不够优秀,而是因为它未能继续回应时代的变化。

这也引出了一个并不新,却常被忽略的事实:

再优秀的库,如果缺乏持续维护与演进,都无法抵御语言与时代本身的变化。

于是,一个新的问题被摆在面前:在语言接管流程之后,网络层还应该承担什么?

重新思考

当网络请求的传输与调度被语言层大幅简化之后,人们开始重新审视网络层本身的价值与边界。关注点不再是如何完整地接管请求生命周期,而是转向更基础的问题:如何让网络能力足够可组装、可测试、可替换,以及可观测。在这一语境下,网络层反而开始变“薄”——不再试图包揽流程,而是清晰地暴露能力边界。

从这个角度看,Moya 所代表的 API 描述 DSL 思路本身并未过时。过时的并不是“用 DSL 描述接口”这件事,而是围绕这一思路所构建的 API 形态,未能随着语言能力的演进而持续调整。如果回到 Moya 的源码与早期讨论中,会发现一个颇具时代感的事实:Moya 从一开始,便将自己定义为 Alamofire 的二次封装。这一定位在当时是合理且务实的——它使得 Moya 能够迅速建立在成熟执行层之上,专注解决接口组织的问题。

但也正是这一自我定位,在无形中为它设定了边界。当语言开始原生承担异步流程与生命周期管理之后,一个以“封装某个执行框架”为前提的 DSL,便很难继续向语言层靠近。这并非设计失误,而是时代变化所带来的结构性结果。许多早期的讨论与 issue,已经敏锐地意识到了这一张力,只是在当时的语境下,尚不足以推动一次彻底的转向。更别说之后的 Swift Concurrency 了。

当网络层变薄之后

当异步流程、错误传播与生命周期管理被语言原生承担之后,网络层本身所剩下的事情,其实变得异常简单。

在今天的语境下,一次网络请求不再需要被层层包装,也不需要被过度建模。它可以被拆解为几个极其清晰、彼此解耦的步骤:

  • 通过某种协议定义,构建一个 URLRequest
  • 通过某种传输机制(Transport)发出请求
  • 接收响应数据或者错误
  • 将结果解码为某种业务所需的模型

这里并不存在一个“中心调度者”,也不存在隐式的流程控制。每一步都只是一个普通的函数调用,每一个内容都一被配置或者可插拔,每一个节点都拥有明确的输入与输出,并且都可以自然地抛出错误。

仅此而已,这就足够了。

但到这里, 许多读者估计会感到非常一位, 聊了这么久, 最后的结论就仅仅是这个? 对的, 结果就是这个, 有些简单、有些朴素。 但这个就是当前语境的最优解, 也是你回望过去得到的最优解。你可以使用任何形式的 API 来构建 URLRequest:无论是链式调用,还是拦截器式的配置;你也可以选择官方的 URLSession 作为传输实现,或者继续使用 Alamofire。这些选择本身并不重要,因为它们都是可替换的实现细节。

真正重要的,并不是你是否使用了某一个具体的网络库,而是你的系统是否允许它被替换。当网络层足够薄、足够中立时,底层实现的变化不再具有破坏性:它不会牵动业务结构,也不会迫使上层逻辑随之重写。在这样的前提下,讨论“是否还需要使用 Alamofire”,就不再是一场立场之争,而只是一次技术选择;系统也不会因为某个 API 的演进或退场,而整体失衡。

After Moya

在这条演进路径上,很难绕开 Moya。对我而言,它并不仅仅是一个网络库,更像是一次提前到来的启蒙。在刚入行的阶段,我第一次从网络层的视角,真正意识到“接口组织”“抽象边界”与“工程结构”这些概念的存在。那些一次又一次出现的架构图与层层抽象,并非晦涩,而是单纯地超出了当时的经验边界。

随着 Swift 语言范式的演变进化,Moya 所依赖的那套抽象语境不再能对的上实际语境。面对上百个的 issue 与尚未完整的Feature,这些内容已并非一两次重构所能解决的问题。也许在这个时代,之后的世界没有承载Moya的船只了。

伤感虽伤感, 但是日子总归都得向前, 项目中依然需要适应全新时代语言的网络层架构, 我开始重新审视那些曾经影响过自己的设计内容, 重新放回到当下的语言与工程语境之中。在这样的背景下,我为新的尝试取了一个名字——Moira。 它并非为了替代什么,而只是希望将那些曾经成立、并仍然有价值的思想,继续传递下去。

又一次的循环

回到最初的问题,为什么网络层总在被重构?

或许所谓的“重构”,从来不是推翻,而是一次次回到原点后的再出发。

在语言不断演进、经验不断累积的过程中,我们反复校准网络层的责任边界,也在反复确认:什么值得被保留,什么应该被交还。

于是循环继续向前——

每一次回望,都让设计变得更简单一些;

每一次重写,都是让设计更贴近当下的语境。

也许我们此刻所做的事情,

与几十年前那些在车库里反复试错的人,并无本质上的不同。

❌