普通视图

发现新文章,点击刷新页面。
今天 — 2025年7月3日首页

前端视角下认识AI Agent

作者 星始流年
2025年7月3日 17:43

当 AI 需要从"说"到"做"

大家好!今天我们来从前端视角介绍一下AI Agent。

在过去的一年里,我们见证了大型语言模型(LLM)的飞速发展。从 ChatGPT 到各种开源模型,它们强大的对话和内容生成能力,已经深刻地改变了我们获取信息和进行创作的方式。无论是写代码还是做翻译,AI 都已成为我们身边不可或缺的助手。但时至今日,我们对 AI 的期望已经不再满足于只能简单的"聊一聊", 我们更希望 AI 能够为我们处理更复杂的任务,在这一方面,单纯的 LLM 开始显得力不从心。

让我们设想一个具体的场景。假如我想去成都旅游五天,并向一个标准的 LLM 发出指令:"请综合考虑天气情况,为我规划一个从 明天到 7 月 5 日的成都详细旅游方案" 这里我们选择调用阿里最新的开源模型Qwen3-turbo模型会迅速生成一份看似周全的计划:

image.png

这份计划看上去内容详实,但仔细推敲就会发现几个关键问题:

  1. 日期不准确:我提问的日期是7月3日,正常来说应该规划的是7月4日到7月5日的旅行行程,它给出的是7月1日-7月5日的计划。
  2. 信息不实时:它提供的天气状况是基于历史数据的模糊描述,而非精准的实时天气预报。

很显然,这样的 LLM 无法真正解决我们的问题。我们需要的,不仅仅是一个信息提供者,而是一个能够理解我们的目标,并能自主完成一系列操作的智能"执行者"。

AI Agent 是什么

为了填补"信息"与"行动"之间的这道鸿沟,一个全新的概念应运而生,它就是我们今天的主角——AI Agent。自2025 年年初开始,AI Agent 正加速走向成熟,并开始在各个领域展现出巨大的潜力。相信不少人已经听说过了这个概念,但 AI Agent 究竟是什么?

简单来说,它不再仅仅是一个会"说"的模型,而是一个会"做"的系统。它以LLM为"大脑"进行思考和规划,并能通过调用各种"工具"(如搜索引擎、计算器甚至是各类应用的 API)来与现实交互或者执行任务,从而将复杂的目标分解并付诸实践。

事实上,这种转变已经悄然发生。例如,现在我们向最新版的 ChatGPT 提出同样的问题,得到的答案会大不相同:

image.png

我们会发现,ChatGPT 已经能够提供基于实时天气预报的建议,而且日期也准确。这正是因为它不再局限于自身的知识库,而是在回答之前,主动调用了联网搜索等工具来查询最新信息。

这种从被动回答到主动执行的转变,正是 AI Agent 的核心思想,也标志着 AI 正从一个"聊天伙伴"进化为一个真正的"智能代理"。


AI Agent 的构成

现在大家常用一个经典公式来概括它的核心构成:

AI Agent = LLM(大脑) + Memory(记忆) + Planning(规划) + Tools(工具)

image.png

让我们来逐一拆解这四个核心组件:

  • LLM (大语言模型): Agent 的核心引擎,充当"大脑"的角色。它负责理解用户意图,进行推理、分析和决策。所有复杂的逻辑判断和语言理解,都由它来完成。

  • Planning (规划): Agent 的"思考框架"。当面对复杂任务时(比如"规划旅行"),Agent 需要将其分解成一系列可执行的小步骤(1. 查天气 → 2. 查酒店 → 3. 查景点 → 4. 规划行程)。这种任务分解和规划能力是 Agent 自主性的关键。

  • Memory (记忆): Agent 的"笔记本"。它能记住之前的交互历史、任务的中间结果,甚至过去的成功经验和失败教训。这使得 Agent 在多轮对话和长期任务中能保持上下文连贯,而不是只有"七秒钟记忆的金鱼脑袋"。

  • Tools (工具): Agent 的"双手",是它与现实世界交互的桥梁。无论是 API 调用(如查询天气)、数据库查询,还是近期热门的 MCP Server 概念,这些都属于工具的范畴。


演示项目介绍

在理论讲解之前,让我先为大家介绍今天的演示项目——旅行规划助手。这个项目将贯穿后续的整个分享,帮助大家理解 AI Agent 从理论到实践的完整转化过程。

项目概览

image.png

这是一个基于多 Agent 协作的智能旅行规划系统。用户只需输入简单的旅行需求,比如"我想 7 月 6 号在成都玩三天,预算 3000 元左右",系统就会自动完成以下流程:

  1. 理解需求 - 提取目的地、时间、预算等关键信息
  2. 收集数据 - 调用真实天气 API 获取实时天气预报
  3. 智能规划 - 结合天气情况生成详细的逐日行程
  4. 实时反馈 - 可视化展示 AI"思考"的完整过程

系统架构

该演示项目采用了多 Agent 协作的架构模式,每个 Agent 专注于特定的任务领域:

image.png

当前项目中有三个专家Agent,分别是:

  • AnalyzerAgent(需求分析专家): 负责解析用户输入,提取目的地、时间、预算等关键信息。
  • WeatherAgent(天气查询专家): 专门处理天气相关查询,结合天气情况生成旅行期间的出行建议。
  • PlannerAgent(行程规划专家): 综合前两个 Agent 的分析结果,生成详细且实用的旅行方案。

所有 Agent 之间的协作流程和数据传递都由AgentCoordinator(协调器)统一管理,并将最终结果实时展示给用户。

当前项目中使用了 3 个工具,分别是:

  • getCurrentDateTool: 获取当前日期(特在用户输入相对时间如"明天"、"下个月 1 号"等场景下)。
  • getLocationIdTool: 将城市名转换为天气 API 所需的 LocationId(由于天气 API 的特殊要求,查询时只接受地点对应的LocationId作为入参)。
  • getWeatherTool: 调用 API 获取LocationId对应地点指定日期的天气数据。

AI Agent 的核心

LLMAI Agent的核心,在AI Agent系统中,这种核心地位体现在系统中的每个子Agent都只是通过prompt预先规划好的LLM。脱离LLM,Agent的智能性就无从谈起,更无法主动完成任务。

演示项目中,每个子 Agent 背后都调用了 Qwen3 的 API,通过 prompt 为 LLM 赋予特定的角色设定和任务目标。在实际开发中,不同的 Agent 也可以调用不同的 LLM 模型,从而实现专业化分工,让每个 Agent 在各自领域发挥最大优势。比如,处理复杂任务可以调用参数量大的LLM提升准确性,针对简单项目可以调用参数量较小的LLM来提升效率,针对特定领域的任务可以调用专门微调过的模型。

export const analyzerAgent: Agent = {
  name,
  description,
  inputPrompt,
  inputExample,
  systemPrompt: `
    ## 角色设定
    ${description}
    ## 任务描述
    你需要根据用户的需求,从中提取出如下与旅行相关的信息:
    1. 目的地[destination], 用户想要去的地方. 必填, 输出文本, 如'北京'
    2. 旅行天数[duration], 用户计划旅行的天数. 必填, 输出不带单位的纯数字, 如'3'
    3. 出发时间[startDate], 用户计划出发的时间. 必填, 输出文本格式为'YYYY-MM-DD', 如'2025-07-01'
    4. 所有日期[allDates], 用户计划旅行的所有日期. 必填, 多个日期之间用','分割, 如'2025-07-01,2025-07-02,2025-07-03'
    5. 预算范围[budget], 用户旅行的总预算. 必填, 输出不带单位的纯数字, 如'1000'
    6. 偏好类型[preferences], 用户偏好的景点或地点类型. 选填, 输出文本, 如'美食'
    7. 额外要求[extraRequirements],用户旅行的额外要求. 选填, 输出文本, 如'必须去故宫'
    ## 输入信息
    ${inputPrompt}
    ## 输入示例
    ${inputExample}
    ## 输出格式
    请以JSON格式返回结果。
    ## 请按照以下格式返回结果:
    {
      "destination": "目的地", 
      "duration": "旅行天数",
      "startDate": "出发时间",
      "allDates": "所有日期",
      "budget": "预算范围",
      "preferences": "偏好类型",
      "extraRequirements": "额外要求"
    }
    ## 示例[必须严格参考格式与风格]
    ### 示例1
    #### 用户输入
    2025年7月1日去北京3天,预算1200元,我喜欢自然风光, 最想去故宫
    #### 输出
    {
      "destination": "北京",
      "duration": 3,
      "startDate": "2025-07-01",
      "allDates": "2025-07-01,2025-07-02,2025-07-03",
      "budget": "1200",
      "preferences": "自然风光",
      "extraRequirements": "去故宫"
    }
    ### 示例2
    #### 用户输入
    今年国庆节在成都玩两天,每天计划400元
    #### 输出
    {
      "destination": "成都",
      "duration": 2,
      "startDate": "2025-10-01",
      "allDates": "2025-10-01,2025-10-02",
      "budget": "800",
      "preferences": "",
      "extraRequirements": ""
    }
`,
  async getJSONResult(
    input: AgentInput,
    addRecord?: (record: RecordItem) => void
  ): Promise<string> {
    addRecord?.({
      id: `analyzerAgent_${Date.now()}`,
      name: name,
      type: "agent",
      desc: `开始分析...`,
      content: input.query,
      contentType: "text",
      createdAt: Date.now(),
    });
    const plan = await chatCompletion(
      {
        messages: [
          { role: "system", content: this.systemPrompt },
          { role: "user", content: input.query },
        ],
        tools: [tools.getCurrentDateTool.schema],
      },
      (toolName) => {
        if (toolName === tools.getCurrentDateTool.schema.function.name) {
          const currentDate = tools.getCurrentDateTool.execute();
          addRecord?.({
            id: `analyzerAgent_${Date.now()}`,
            name: toolName,
            type: "tool",
            desc: `获取当前日期...`,
            content: currentDate,
            contentType: "text",
            createdAt: Date.now(),
          });
          return `当前日期是${currentDate}`;
        }
        return "";
      }
    );
    addRecord?.({
      id: `analyzerAgent_${Date.now()}`,
      name: name,
      type: "agent",
      desc: `分析完成`,
      content: plan,
      contentType: "json",
      createdAt: Date.now(),
    });

    return plan;
  },

  async makeTextResult(
    planResult: string,
    addRecord?: (record: RecordItem) => void
  ): Promise<string> {
    try {
      const parseResult = JSON.parse(planResult);
      addRecord?.({
        id: `analyzerAgent_${Date.now()}`,
        name: name,
        type: "agent",
        desc: `开始格式化输出...`,
        content: planResult,
        contentType: "json",
        createdAt: Date.now(),
      });

      // 生成结构化的分析结果
      const analysis = `
    **用户旅行意图分析结果**
    🏕️ **目的地**:${parseResult.destination}
    ⏰ **旅行天数**:${parseResult.duration}天
    📅 **出发时间**:${parseResult.startDate}
    📅 **所有日期**:${parseResult.allDates}
    🎯 **偏好类型**:${(parseResult.preferences || ["观光"]).join("、")}
    💰 **预算范围**:${parseResult.budget || "中等"}
    👥 **出行人数**:${parseResult.travelers || 1}人**
    `;

      addRecord?.({
        id: `analyzerAgent_${Date.now()}`,
        name: name,
        type: "agent",
        desc: `格式化输出完成`,
        content: analysis,
        contentType: "text",
        createdAt: Date.now(),
      });
      return analysis;
    } catch (error) {
      console.error("需求组织失败:", error);
      return `需求组织失败: ${error}`;
    }
  },
};

在应用层,针对LLM能做一般只有sdk的调用和prompt的设计。

LLM SDK的调用

现在绝大多数LLM都兼容openai的调用方式,sdk相对简单,下面我们重点介绍工具调用的实现机制。

import OpenAI from "openai";

// 基础模型API调用
export async function chatCompletion(
  options: ChatCompletionOptions,
  toolCallBack?: (
    toolName: string,
    toolArgs?: Record<string, any>
  ) => string | Promise<string>
): Promise<string> {
  try {
    const params: any = getParams(options);

    const response = await client.chat.completions.create(params);

    if (response.choices[0]?.message?.tool_calls?.length) {
      const message = response.choices[0].message;
      const toolCall = response.choices[0].message?.tool_calls[0];
      const toolName = toolCall.function.name;
      const toolArgs = JSON.parse(toolCall.function.arguments);
      const toolResult = await toolCallBack?.(toolName, toolArgs);
      let toolInfo = {
        role: "tool" as const,
        content: toolResult?.toString() || "",
      };

      const newParams: any = getParams({
        ...options,
        messages: [
          ...options.messages,
          message as ChatMessage,
          toolInfo,
        ],
      });
      return chatCompletion(newParams, toolCallBack);
    } else {
      return response.choices[0]?.message?.content || "";
    }
  } catch (error) {
    console.error("API调用失败:", error);
    throw new Error("AI服务暂时不可用,请稍后再试");
  }
}

Prompt的设计

良好的prompt设计可以使LLM更有效更准确的完成目标任务。设计prompt已经成为了一门专门的工程技术。由于其涉及的内容较多,本文不展开讲。感兴趣的可以查看prompt工程指南


AI Agent 如何"行动"

前面我们提到,LLM 是 Agent 的大脑,Tools 是 Agent 的四肢。从本质上讲,Tools 就是 LLM 获取外部信息和执行操作的方式,它可以是 Web API 调用、数据库查询、文件读写操作等。

在我们的旅行规划助手中,使用了两种不同形式的工具:

  1. 纯函数形式的工具: getCurrentDateTool
  2. Web API 形式的工具: getLocationIdToolgetWeatherTool

虽然这两种工具的实现形式不同,但它们与 LLM 的交互方式都是Function Calling(函数调用)

Function Calling

Function Calling是什么

Function Calling 的核心思想是:我们用代码定义函数,并将函数的描述信息(函数名、功能说明、参数列表及类型)提供给 LLM。当 LLM 在"思考"阶段认为需要执行某个操作时,它不会直接执行,而是生成一个包含函数名和参数的特定格式 JSON 对象,请求我们调用相应函数。我们解析这个 JSON 后,在代码环境中执行对应函数,再将执行结果返回给 LLM,LLM 会结合最新结果开始新一轮的"思考"

image.png

上方这张图完整地展示了 模型 调用外部工具函数工作流程:

  1. 开发者首先定义一个查询指定地点天气的工具函数 get_weather(location)。同时,将该定义以及消息:“What’s the weather in Paris?” 发送给了模型。
  2. 模型分析后,决定使用该工具。它不会自己执行,而是返回一个 Tool Calls 的"指令"以及对应的参数'Paris'。
  3. 开发者解析模型返回的结果后,将参数传递到本地的工具函数中并调用。
  4. 本地函数返回结果 { temperature: 14} 后,我们将这个结果并入之前的消息再重新发送给模型。
  5. 型整合信息,生成最终自然语言回答:“It’s currently 14°C in Paris.”。

Function Calling的调用

在函数调用中主要有两个关键步骤,我们以旅行规划助手中的 getLocationIdTool 为例进行详细讲解:

1. 工具函数定义阶段

函数定义的目的是告知模型自身的用途以及需要哪些参数。它包括以下字段:

  • name: 工具函数的名称
  • description: 详细描述何时以及如何使用该函数
  • parameters: 定义函数输入参数的 JSON Schema
{
  "type": "function",
  "function": {
    "name": "get_location_id",
    "description": "将地点名称转换为查询天气所需的LocationId",
    "parameters": {
      "type": "object",
      "properties": {
        "address": {
          "type": "string",
          "description": "地点名称,如'九寨沟'"
        }
      },
      "required": ["address"]
    }
  }
}

在函数定义时,建议遵循以下最佳实践:

  • 明确描述函数和每个参数的格式和用途,以及输出内容的含义
  • 通过 system prompt 准确描述模型应该何时(以及何时不)使用各个函数
  • 提供少量示例来帮助模型更好地理解函数的使用场景

完成定义后,需要将定义发送给模型,模型会根据定义来决定是否使用该工具。

import { OpenAI } from "openai";
const openai = new OpenAI();

const tools = [
  {
    type: "function",
    function: {
      name: "get_location_id",
      description: "将地点名称转换为查询天气所需的LocationId",
      parameters: {
        type: "object",
        properties: {
          address: {
            type: "string",
            description: "地点名称,如'九寨沟'",
          },
        },
        required: ["address"],
      },
    },
  },
];

const messages = [
  {
    role: "user",
    content: "成都对应的LocationId是多少?",
  },
];

const completion = await openai.chat.completions.create({
  model: "qwen-turbo",
  messages,
  tools,
});

请求参数: image.png

2. 工具函数调用阶段

当模型需要调用函数时,响应中会包含一个 tool_calls 数组,每个元素都有一个 id 和一个包含 name 及参数的 function 对象。

响应结果:

image.png

接下来我们解析 function 对象中的 namearguments 字段,并调用对应的函数。

async function getLocationId({ address }: { address: string }) {
  return new Promise((resolve, reject) => {
    fetch(
      `/qweatherapi/geo/v2/city/lookup?location=${address}&key=${qweatherApiKey}`
    )
      .then((res) => res.json())
      .then(({ location }) => {
        resolve(location[0].id);
      })
      .catch((err) => {
        reject(err);
      });
  });
}

const toolCalls = completion.choices[0].message.tool_calls;

for (const toolCall of toolCalls) {
  const name = toolCall.function.name;
  const args = JSON.parse(toolCall.function.arguments);

  if (name === "get_location_id") {
    const result = await getLocationId({address:args.location});
    console.log(result);
  }
}

执行完毕后,需要将工具调用的结果返回给模型,模型会根据结果继续生成响应。

messages.push(completion.choices[0].message); // 将模型生成的消息添加到消息列表中
messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: result.toString(),
}); // 将工具调用的结果添加到消息列表中

// 重新调用模型
const completion = await openai.chat.completions.create({
  model: "qwen-turbo",
  messages,
  tools,
});

新调用的请求参数:

image.png

之后LLM就会根据复合的消息内容生成最终的结果。

MCP

除了传统的 Function Calling,AI Agent 的工具调用正朝着MCP (Model Context Protocol) 方向快速发展。

MCP 是由 Anthropic 提出的开放协议,其目的是在为 AI 应用和外部数据源/工具之间建立安全、标准化的连接。

MCP的架构

和 Function Calling 简单直接的调用不同, MCP 遵循的是 Client-Server 架构,下方有一个 MCP 架构的示意图:

image.png

  • MCP Hosts: 发起 MCP 请求的宿主应用程序,"旅行规划助手"这个项目就可以看做是一个 MCP 宿主应用。宿主应用内部一般会集成一个或多个MCP客户端,宿主应用负责任务编排,管理对话状态以及将需要外部数据或信息的调用任务分派给MCP客户端。
  • MCP Clients: MCP客户端是在宿主应用内部与MCP服务器通信的代理,客户端负责与MCP服务器进行协议交互,包括能力协商(handshake)、请求转发、结果接收等。当模型在对话中需要调用某个工具或获取资源时,宿主会通过对应的客户端向服务器发送请求。
  • MCP Server: 提供 MCP 服务的实体,可以是独立进程,也可以是服务。可以运行在本地,也可以运行在远端。MCP 服务器会连接到实际的后端系统(数据库、文件系统、外部API等),并按照MCP规范提供统一的接口供MCP客户端调用。

MCP的通信方式

MCP基于JSON-RPC 2.0协议进行通信,所有消息(请求、响应、通知、错误)均采用JSON结构。它支持两种主要传输方式:STDIO(标准输入输出,适用于本地集成)和HTTP+SSE(基于HTTP的Server-Sent Events,用于远程服务)。本地部署时,宿主可启动一个MCP服务器进程,并通过STDIO管道直接读写数据;远程部署时,客户端通过HTTP连接服务器的SSE端点,保持长连接以接收服务器推送的消息。

image.png

详细了解可以查看modelcontextprotocol


多 Agent 协作

对于需求相对简单的项目(如我们的旅行规划需求),使用单个 Agent 完全可以胜任。但随着系统功能日益复杂,单个 Agent 往往会力不从心。此时我们可以考虑将不同功能模块拆分给专门的 Agent,并将它们组合成一个 Multi-Agent 系统。

使用多代理系统的主要优势包括:

  • 任务聚焦: 每个 Agent 可以专注于特定领域,成为该领域的"专家",比如专门写代码的"程序员 Agent"和专门做 UI 设计的"设计师 Agent"。这比让一个"全能"Agent 处理所有事务要高效且可靠得多。
  • 问题分解: 可以将复杂问题分解给不同的 Agent,支持并行处理或接力完成。
  • 独立优化: 我们可以独立优化和升级某个专家 Agent,而不影响整个系统的其他部分。

Multi-Agent 架构模式

Multi-Agent 架构通常有以下几种主要模式:

  • Network(网络模式): 每个代理都可以与其他每个代理直接通信,任何代理都可以决定接下来要调用哪个其他代理。
  • Supervisor(主管模式): 每个代理都与一个主管代理通信,主管代理负责决定接下来应调用哪个代理。
  • Hierarchical(分层模式): 代理可以有多个层级,每个层级可以有多个代理。
  • Custom Workflow(自定义工作流模式): 各代理仅与一部分代理通信,流程的各个部分是确定性的,只有其中一些代理可以决定接下来要调用哪些其他代理。

image.png

演示项目中的"主管-专家"模式

在演示项目中,我们使用的是 Supervisor 模式。由 Coordinator 扮演主管的角色,按照预编排好的静态流程,协调专家 Agent 的调用逻辑并管理 Agent 之间的信息传递。具体的流程如下:

用户指令: 我想7月6号在成都玩三天,预算3000元左右

  1. AgentCoordinator 接收到指令后, 按预定顺序分发任务:

    • Step 1: "AnalyzerAgent,提取用户需求:目的地、时间、预算等关键信息"
    • Step 2: "WeatherAgent,根据分析结果查询成都 7 月 6-8 日的天气情况"
    • Step 3: "PlannerAgent,结合需求分析和天气信息,生成详细的三天行程"
  2. 三个专家 Agent 依次执行:

    • AnalyzerAgent 调用 getCurrentDateTool 获取当前日期
    • WeatherAgent 调用 getLocationIdTool, getWeatherTool 获取天气数据
    • PlannerAgent 综合前两个 Agent 的结果,生成详细方案
  3. 专家 Agent 完成后,将结果返回给 AgentCoordinator,然后交由页面进行展示

这种模式大大提升了系统的模块化、可扩展性和处理复杂问题的能力。

在演示系统中,为了使不同的 Agent 之间能够顺畅地传递信息,我们必须严格控制前一个 Agent 的输出格式。如果各个Agent由不同的团队协作开发,如何提高协作效率,减少因输出格式不一致而导致的沟通成本呢?这里就引出了一个新概念:A2A

A2A

与 MCP 类似,A2A 也是一种开放的标准化协议。不同的是,A2A 专门面向不同 AI 代理之间的信息传递场景,定义了代理之间如何交换数据以及如何处理这些数据。

image.png

A2A 是由 Google 在 2024 年 4 月推出的标准,目前仍在完善中,因此这里不做深入展开。更详细的信息可以参考 A2A 官方文档


AI Agent 如何"思考"

不知各位有没有注意到,前文中描述AI Agent时反复提到一个关键词自主。AI Agent自主性的基石是LLM,那么如何让AI Agent能够更加自主的思考和规划呢?

这就涉及两种核心的AI Agent模式:

  • Plan-and-Execute(规划-执行模式)
  • ReAct(推理-行动框架)

Plan-and-Execute 模式

演示项目中的 AgentCoordinator 使用的是静态的 Agent 协作流程,在编排层面无需 Agent 参与。我们预先规定了每个 Agent 的执行顺序以及输入输出格式。这种模式对于当前的简单需求完全够用,但当需要构建能够处理复杂任务的 Agent 时,静态编排方式就力不从心了。

// 静态编排流程
async execute(userQuery: string): Promise<AgentResults> {
    try {
      //  第一步:需求分析
      const { textResult: analysisResult, jsonResult: analysisJsonResult } =
        await this.executeAgentWithCallback(analyzerAgent, userQuery);
      this.addRecord({
        id: `coordinator_${Date.now()}`,
        name: this.name,
        type: "supervisor",
        desc: "需求分析任务完成",
        content: "",
        contentType: "",
        createdAt: Date.now(),
      });

      // 第二步:天气收集
      const { textResult: weatherResult, jsonResult: weatherJsonResult } =
        await this.executeAgentWithCallback(
          weatherAgent,
          `旅行地点: ${JSON.parse(analysisJsonResult).destination};旅行日期: ${
            JSON.parse(analysisJsonResult).allDates
          }`
        );
      // 第三步:行程规划
      const { jsonResult: plannerJsonResult, textResult: plannerResult } =
        await this.executeAgentWithCallback(
          plannerAgent,
          `
          用户旅行意图分析结果:
          ${analysisResult}
          用户旅行期间的天气信息与建议:
          ${weatherResult}
          `
        );

      // 返回包含所有Agent结果的完整数据
      return {
        analysis: {
          text: analysisResult,
          json: analysisJsonResult,
        },
        weather: {
          text: weatherResult,
          json: weatherJsonResult,
        },
        planner: {
          text: plannerResult,
          json: plannerJsonResult,
        },
      };
    } catch (error) {
      console.error("Agent协作执行失败:", error);
      throw error;
    }
  }

举个例子,假设我们要构建一个"办公助手"系统,用户可以提出任何办公相关的任务:

  • 用户输入:"帮我写一篇关于 AI Agent 的报告"

    • Agent 调用链:联网搜索 Agent → 报告生成 Agent → 报告审核 Agent
  • 用户输入:"帮我分析这份 Excel 中的数据并绘制图表"

    • Agent 调用链:数据分析 Agent → 图表生成 Agent
  • 用户输入:"请为这份分析好的数据绘制相应的图表"

    • Agent 调用链: 图表生成 Agent

不同目标对应的 Agent 调用链完全不同,显然无法通过硬编码实现流程编排。这时我们可以依赖 LLM 来分析用户需求,制定 Agent 调用计划,然后按计划执行。这种 "制定计划 → 按计划执行" 的过程,就是 Plan-Execute 模式的核心思想。

Plan-Execute 模式有两个核心阶段:

  1. 规划(Planning)阶段:Agent 首先对整个复杂任务进行宏观分析,生成高层次的执行计划(步骤列表)。
  2. 执行(Execution)阶段:Agent 按照制定好的计划逐一执行每个步骤,执行完毕后可根据需要调整计划。

结合这种模式,我们可以将'主管-专家'架构中的'主管'角色设计为一个使用LLM的Agent,由这个Agent去根据用户的输入以及各个专家agent的能力,动态制定Agent调用计划。

我们可以改造演示项目,编写一个名为SmartSupervisor的主管Agent

/**
 * 主管Agent
 * 通过大模型分析用户输入,智能决定Agent调用顺序和选择
 */
export class SmartSupervisor {
  public name = "SmartSupervisor";
  private plan: TaskPlan | null = null;
  private outputPool: any[] = [];
  private finalResult: AgentResults = {
    analysis: {
      text: "",
      json: "",
    },
    weather: {
      text: "",
      json: "",
    },
    planner: {
      text: "",
      json: "",
    },
  };
  private addRecord: (record: RecordItem) => void;
  private gateKeeper: GateKeeper;

  private readonly planningPrompt = `
  ## 角色设定
  你是一名资深的Agent管理专家,在你的团队下有"PlannerAgent"、"WeatherAgent"、"AnalyzerAgent"三个专家Agent。你的任何是根据用户的输入,规划出合理的Agent执行计划。
  ## 团队中的各Agent的系统设定如下(系统设定中含有角色设定, 任务描述, 输入信息, 输出格式以及示例等):
  - PlannerAgent: ${plannerAgent.systemPrompt}
  - WeatherAgent: ${weatherAgent.systemPrompt}
  - AnalyzerAgent: ${analyzerAgent.systemPrompt}
  ## 规划原则
  1. **熟悉团队成员**: 熟悉团队各Agent的系统设定, 熟悉他们的职责以及要求的输入信息, 输出格式等
  2. **信息完整性分析**: 分析用户已提供的信息,确定缺失的关键信息
  3. **Agent选择优化**: 结合团队中各Agent的系统设定, 确定需要调用的Agent
  4. **执行顺序优化**: 根据信息依赖关系确定最优调用顺序
  5. **效率优先**: 在保证质量的前提下,尽量减少Agent调用次数
  6. **不要代劳**: 只完成你的规划工作, 不要代劳团队成员职责内的任务
  7. **智能规划**: 如果用户输入中已经包含了某个Agent的职责内的信息, 则不需要调用该Agent

  ## 输出格式
  请严格按照以下JSON格式输出执行计划:
  你应该输出一个数组,数组中每个元素都是一个对象,对象中包含agentName和reason字段,agentName为当前agent的名称,reason为选择当前agent的原因。
  ### 输出示例:
  [
      {
        agentName: "当前agent的名称",# AnalyzerAgent"|"WeatherAgent"|"PlannerAgent"
        reason: "选择当前agent的原因",
      },
      {
        agentName: "当前agent的名称",# AnalyzerAgent"|"WeatherAgent"|"PlannerAgent"
        reason: "选择当前agent的原因",
      }
    ]
  `;

  constructor(addRecord: (record: RecordItem) => void) {
    this.addRecord = addRecord;
    this.gateKeeper = new GateKeeper(addRecord);
  }

  /**
   * 主要的任务执行入口 - Plan-and-Execute模式
   */
  async execute(query: string): Promise<any> {

    this.outputPool = [
      {
        makerName: "user",
        outputText: query,
        outputJson: "",
      },
    ];
    // Plan阶段:通过LLM智能规划
    this.plan = await this.intelligentPlanning(query);

    // Execute阶段:执行规划的任务
    const result = await this.executeStep(this.plan!);

    return result;
  }

  /**
   * 智能规划阶段 - 通过LLM分析用户输入,制定执行计划
   */
  private async intelligentPlanning(query: string): Promise<any> {
    const userInput = `用户输入为: "${query}"`;

    const response = await chatCompletion({
      messages: [
        { role: "system", content: this.planningPrompt },
        { role: "user", content: userInput },
      ],
    });

    const planData = JSON.parse(response);

    const planId = `intelligent_plan_${Date.now()}`;

    // 根据LLM建议的执行顺序创建步骤
    const steps: PlanStep[] = planData.map(
      (agentConfig: any, index: number) => {
        const step: PlanStep = {
          id: `step_${agentConfig.agentName.toLowerCase()}_${
            Date.now() + index
          }`,
          title: agentConfig.agentName,
          description: agentConfig.reason,
          inputs: {},
          outputs: {},
          done: false,
          observeDone: false,
          error: null,
        };
        return step;
      }
    );

    const plan: TaskPlan = {
      id: planId,
      steps,
      originalInput: query,
      createdAt: Date.now(),
    };

    return plan;
  }

  /**
   * 执行计划
   */
  private async executeStep(plan: TaskPlan): Promise<any> {
    let index = 0;
    for await (const step of plan.steps) {
      console.log(`🔄 [${this.name}] [execute] 执行步骤: ${step.title}`);
      this.addRecord({
        id: `plan_${Date.now()}`,
        name: this.name,
        type: "supervisor",
        desc: `分配任务到: ${step.title}`,
        content: "",
        contentType: "",
        createdAt: Date.now(),
      });
      const agent = {
        AnalyzerAgent: analyzerAgent,
        WeatherAgent: weatherAgent,
        PlannerAgent: plannerAgent,
      }[step.title];

      const gateKeeperResult = await this.gateKeeper.makeParams({
        agentName: step.title,
        outputPool: this.outputPool,
      });

      const jsonResult = await agent!.getJSONResult(
        {
          query: gateKeeperResult.targetAgentInput,
        },
        this.addRecord
      );

      const textResult = await agent!.makeTextResult(
        jsonResult,
        this.addRecord
      );

      this.outputPool.push({
        makerName: step.title,
        outputText: textResult,
        outputJson: JSON.parse(jsonResult),
      });

      this.plan!.steps[index].outputs = {
        jsonResult: jsonResult,
        textResult: textResult,
      };
      index++;
    }

    for (const step of this.plan!.steps) {
      if (step.title === "AnalyzerAgent") {
        this.finalResult.analysis = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      } else if (step.title === "WeatherAgent") {
        this.finalResult.weather = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      } else if (step.title === "PlannerAgent") {
        this.finalResult.planner = {
          text: step.outputs?.textResult,
          json: step.outputs?.jsonResult,
        };
      }
    }
    return this.finalResult;
  }
}

此时运行项目得到的大致执行流程如下:

用户指令我想7月6号在成都玩三天,预算3000元左右

📝 规划阶段:

这是一个复杂的旅行规划任务,需要分解处理。合理的执行计划应该是:

  1. 调用 AnalyzerAgent 分析用户需求,提取关键信息
  2. 调用 WeatherAgent 查询目的地天气情况
  3. 调用 PlannerAgent 结合前两步结果生成详细行程

🚀 执行阶段:

  • Step 1:需求分析
    • 委派 AnalyzerAgent 执行需求分析
  • Step 2:天气查询
    • 委派 WeatherAgent 执行天气查询
  • Step 3:行程规划
    • 委派 PlannerAgent 执行行程规划

Plan–Execute 模式通过分离“规划”和“执行”,不仅提升了 AI Agent 的可维护性,还能够提升流程执行的灵活性。比如说, 演示项目中如果用户在输入环节已经提供了足够的需求信息以及天气信息, 主管Agent就会忽略AnalyzerAgent和WeatherAgent, 通过PlannerAgent直接生成报告。


ReAct模式

ReAct 的名字非常直观,就是 推理(Reason)行动(Act) 的结合。它模拟了人类解决问题的基本模式:推理 → 行动 → 观察 → 再思考...,形成一个闭环,直到任务完成。

这个模式各个环节所完成的任务如下:

  • 推理:LLM 分析任务并将其分解为多个步骤。它计划要采取的行动,并根据可用的信息和工具决定如何解决问题。
  • 行动:按照计划执行每个步骤,在此期间可以调用工具来获取外部信息。
  • 观察:执行每个动作后,代理会观察结果并将相关信息保存在内存中 。这种跟踪使它能够跟踪过去的作并建立在以前的观察结果之上,因此它不会重复自身或丢失上下文。
  • 再思考:根据观察结果调整计划,并重复上述过程,直到任务完成

我们可以通过对比Plan–Execute来更深入地理解这种模式:

维度 Plan–Execute 模式 ReAct模式
流程结构 两阶段:先整体规划(Plan),再分步执行(Execute)。 交叉式:在同一对话或循环中,推理(Reasoning)行动(Acting) 交替进行。
模块职责 - 规划器:负责生成高层行动计划(步骤序列)。
- 执行器:负责具体动作的落实和监控。
无明显分层,模型在同一上下文中同时承担推理与输出动作指令。
决策节奏 先全局后局部:先确定完整计划,再执行各子步骤。 即推即做:每次推理后立即执行一次动作,依据反馈再推理下一步。
适用场景 - 复杂、多步骤任务(如机器人操作、业务流程自动化)。
- 需要严格顺序与资源分配的场景。
- 信息检索、问答对话等交互式场景。
- 步骤灵活、依赖外部反馈即时调整的任务。

再对比之前的场景,ReAct模式Agent的执行流程大致如下:

用户指令我想7月6号在成都玩三天,预算3000元左右

  • Step 1:需求分析
    • 🧠 Thought(推理):用户提出旅行计划,但没有说明出发地和偏好,第一步我应该解析需求
    • ⚙️ Action:调用 AnalyzerAgent 对用户输入进行信息提取
    • 👁️ Observation(观察):分析结果是否满足要求
  • Step 2:天气查询
    • 🧠 Thought(推理):好的,用户意图基本清晰。为了合理安排行程,我需要知道成都这几天的天气
    • ⚙️ Action:调用 WeatherAgent 查询 7月6日–8日 成都天气
    • 👁️ Observation(观察):分析结果是否满足要求。
  • Step 3:行程规划
    • 🧠 Thought(推理):好,我知道了天气情况。可以开始安排合理的行程
    • ⚙️ Action:调用 PlannerAgent 进行行程规划
    • 👁️ Observation(观察):分析结果是否满足要求

需要注意的是Plan–Execute模式与ReAct模式并不是两个互斥的概念,面对复杂任务时,如果有必要我们可以将两个模式结合起来,构建出更完善可靠的Agent系统。

总结

让我们快速回顾一下核心要点:

  1. AI Agent 的本质LLM + Memory + Planning + Tools,其核心是LLM,关键特性是自主性
  2. 工具调用Function CallingMCP Server都是Agent感知外部世界获取外部世界信息的方式。
  3. 协作架构多 Agent 系统通过专业分工,能够更好地解决复杂问题。
  4. 思考模式Plan-and-ExecuteReAct是两种不同的思考模式,它们之间并不互斥。

AI领域的变化日新月异,各种概念层出不穷。由于时间关系,文中提到的诸如MCP、A2A以及Agent的另一核心部分Memory没有详细展开。构建Agent也有很多其他方面的概念,比如Context、Human-in-the-loop等等本文也没有涉及。建议大家自行再去探索,因为抛开作为AI应用入口的Web端,就AI应用层来说,各大流行SDK或框架所支持的语言除了python一般都是JS/TS(比如 OpenAI SDK、langChain、langGraph、Transformers.js),前端开发者与AI领域的距离并没有想象中那么遥远。

谢谢大家!

昨天以前首页

从SSE到打字机——AI场景下前端的实现逻辑与实践

2025年7月1日 19:34

随着Deepseek的横空出世,让每个人都有了构建自己AI知识库的机会,作为一个前端开发者,完全可以通过大模型构建自己的日常学习知识库,然后自己写一个AI的交互页面构建自己的 ChatGPT ,当然说到这,肯定有人说现在有一键构建的开源项目为什么不用呢,说白了技术还是要自己实现才能更加深入地理解,并且更加灵活地运用到日常学习或者实际业务场景中去。

本篇文章只从前端的角度出发,分析实现一个AI的交互页面能用到哪些技术,最后再去实现一个AI场景页面。

当然,你也可以点击这里直接查看本篇文章实现的页面。

如果打不开,这里还有贴心国内服务器的备用链接

PS:上面两个演示链接都是用 vuepress 实现的个人博客,感觉用这套框架实现自定义组件里面的坑还挺多了,有机会可以再写一篇关于 vuepress 的开发避坑文章。

当然,关于IM的交互逻辑在我之前的文章 【从零开始实现一个腾讯IM即时通讯组件(无UI设计方案)~】中已经详细描述了实现过程,所以,这篇文章就从已经实现了IM交互的页面基础上开始实现AI场景下的IM。

技术选型

涉及到AI场景必然会联想到打字机效果的流式输出文本,那么前端实现这种效果有哪些方式呢?

协议对比

首先最简单的,通过轮询接口不断获取数据,其次通过websocket不断获取监听到的数据,最后通过服务端消息推送获取数据。这三种思路对应着三种通讯协议:HTTP、WebSocket、SSE。

先对比一下这三种协议:

基本概念与通信模式

特性 HTTP SSE (Server-Sent Events) WebSocket
协议类型 无状态的请求 - 响应协议 基于 HTTP 的单向事件流协议 基于 TCP 的全双工实时协议
通信方向 客户端→服务器(单向) 服务器→客户端(单向) 双向(全双工)
连接特性 短连接(每次请求新建连接) 长连接(单次请求,持续响应) 长连接(一次握手,持续通信)
发起方式 客户端主动请求 客户端主动请求,服务器持续推送 客户端发起握手,后续双向通信
典型场景 静态资源请求、API 调用 实时通知、股票行情、新闻推送 实时聊天、在线游戏、协作工具

技术细节对比

特性 HTTP SSE WebSocket
协议基础 HTTP/1.1 或 HTTP/2 HTTP/1.1 或 HTTP/2 WebSocket 协议 (RFC 6455)
端口 80 (HTTP) / 443 (HTTPS) 80/443 80 (ws) / 443 (wss)
数据格式 文本、JSON、二进制等 纯文本(text/event-stream) 文本或二进制(帧格式)
二进制支持 支持,但需额外处理 不支持(需编码为文本) 原生支持
自动重连 否(需客户端实现) 是(内置机制) 否(需手动实现)
心跳机制 否(需轮询) 否(需自定义) 是(Ping/Pong 帧)
浏览器兼容性 全兼容 现代浏览器(IE 不支持) 现代浏览器(IE 10+)

性能与效率

特性 HTTP SSE WebSocket
连接开销 高(每次请求需重新建立连接) 中(一次连接,长期保持) 低(一次握手,持续通信)
协议 overhead 高(HTTP 头信息冗余) 低(仅初始头) 中(帧头开销较小)
实时性 低(依赖客户端轮询) 高(服务器主动推送) 极高(双向实时)
带宽利用率 低(轮询导致无效请求) 中(单向持续传输) 高(按需双向传输)
延迟 高(请求响应周期) 中(推送延迟) 低(长连接直接通信)

API选择

再来回看一下我们的需求,AI场景说白了一问一答的方式,那么我们希望发送一次请求后,能够持续获取数据,本次请求后端也只需要知道我的问题即可,不需要和前端进行其他交互,所以 SSE 在这种场景下的优势就显而易见了。

前端要在浏览器中实现 SSE 的方式有两种:

  • EventSource API
  • fetch API

EventSourcefetch 都是现代 Web 开发中用于与服务器通信的 API。

特性 EventSource (SSE) Fetch API
通信模式 单向(服务器→客户端) 双向(请求→响应)
连接特性 长连接(持续接收服务器推送) 短连接(每次请求新建连接)
数据流类型 事件流(持续不断) 一次性响应(请求完成即结束)
数据格式 文本(事件流格式) 任意(JSON、Blob、文本等)
自动重连 内置支持(自动重连机制) 需手动实现

EventSource API实现了 SSE 。换句话说 EventSource API是 Web 内容与服务器发送事件通信的接口。一个EventSource 实例会对HTTP服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

但是它有一些限制:

  • 无法传递请求体 request body ,必须将执行请求所需的所有信息编码到 URL 中,而大多数浏览器对 URL 的长度限制为 2000 个字符。
  • 无法传递自定义请求头。
  • 只能进行 GET 请求,无法指定其他方法。
  • 如果连接中断,无法控制重试策略,浏览器会自动进行几次尝试然后停止。

而AI场景常常会有一些其他需求,如上文记忆、接口 token 验证等等,于是 fetch 成了我们的最佳选择。

fetch API可以通过设置 headers 支持流式数据的接收,然后通过 ReadableStreamDefaultReader 对象,逐块读取响应的数据。

大模型选择

作为前端开发我们更注重于模型的定制化配置和页面的展示效果与交互,通过第三方模型可以快速满足我们的需求,这里我选用的是阿里云百炼

它直接提供了支持流式输出的接口,只需要在请求头加上 X-DashScope-SSE:true 。比较坑的是阿里云文档里面只提供了 node 的写法,实际浏览器中 axios 并不支持流式传输。

image-20250621145616487

API解析

AbortController

前面我们说到 SSE 的数据传输是单向的,有时候我们会想中断推送信息的接收,实际需求就是中断AI当前回答,所以我们需要一个控制器来更加精细地控制我们的请求。

AbortController 对象的作用是对一个或多个 Web 请求进行中止操作,像 fetch 请求、ReadableStream 以及第三方库的操作都可以取消。

核心机制:借助 AbortSignal 来实现操作的中止。AbortController 会生成一个信号对象,该对象可被传递给请求,当调用 abort() 方法时,就会触发请求的取消操作。

有了这个API我们就可以实现中断回答按钮的实现。

const controller = new AbortController()
const response = await fetch(
  url: 'url',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer token`,
      'X-DashScope-SSE': 'enable', // 允许实时推送
    },
    signal: controller.signal, // 信号对象绑定请求
    body: "{...}",
  }
)

setTimeout(()=> controller.abort(), 1000) // 一秒钟后自动中断请求

Reader

在请求发出后,我们需要一个能持续获取推送信息的入口,fetchresponse.body.getReaderJavaScript 中用于处理 fetch 请求响应流的方法,它允许你以可控的方式逐块读取响应数据,而非一次性处理整个响应。这在处理大文件下载、实时数据流(如视频、SSE)或需要渐进式解析数据的场景中特别有用。

// 获取一个 ReadableStreamDefaultReader 对象,用于逐块读取响应的二进制数据(Uint8Array)。
const reader = response.body.getReader()

while (true) {
  // 读取数据块 流是一次性的,读取完成后无法再次读取
const {done, value} = await reader.read();
  if (done) {
    console.log('流读取完成');
    break;
  }
}

循环调用 read() 以达到获取完整数据的需求,根据 done 判断是否已经读取完毕。

TextDecoder

TextDecoderJavaScript 中用于将二进制数据(如 ArrayBufferUint8Array)解码为人类可读的文字字符串的内置对象。它支持多种字符编码(如 UTF-8ISO-8859-1GBK 等),是处理网络响应、文件读取等二进制数据转换的标准工具。

// 任意二进制数据
const value = ...

// 流式解码:支持分块处理二进制数据(通过多次调用 decode 方法)。
const decoder = new TextDecoder('UTF-8')
// 解码二进制数据为文本
const chunk = decoder.decode(value, { stream: true })

值得注意的是 decodestream 参数设置为 true ,这是为了防止乱码的情况,因为我们知道 UTF-8 是一种变长编码,ASCII 字符(0-127)用 1 个字节表示,而其他字符(如中文、 emoji)可能用 2-4 个字节表示。例如:

  • 的 UTF-8 编码是 [228, 184, 150](3 个字节)。
  • 😊 的 UTF-8 编码是 [240, 159, 152, 138](4 个字节)。

当数据分块传输时,一个字符可能被截断在不同的块中。例如:

块1: [228, 184]    // "中" 的前两个字节(不完整)
块2: [150]         // "中" 的最后一个字节

stream 选项决定了解码器如何处理可能不完整的多字节字符:

stream 行为描述
false 默认值。假设输入是完整的,直接解码所有字节。若遇到不完整字符,会用 替换。
true 假设输入是数据流的一部分,保留未完成的多字节字符,等待后续数据。

实际情况可以参考下段代码:

// 错误情况
const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1)); // 输出: "�"(错误:截断的字符被替换为乱码)
console.log(decoder.decode(chunk2)); // 输出: "�"(错误:单独的第三个字节无法组成有效字符)

// 正确情况

const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1, { stream: true })); // 输出: ""(无输出,保留未完成字符)
console.log(decoder.decode(chunk2));                   // 输出: "中"(合并后正确解码)

处理流式输出

结合上述API的分析,fetch 实现处理流式数据的代码如下:

const controller = new AbortController()

const response = await fetch(
  url,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer sk-dd0e8892eb0445149fd21fd9b1d6176c`,
      'X-DashScope-SSE': 'enable',
    },
    signal: controller.signal,
    body: JSON.stringify({
      input: {
        prompt: text
      },
      parameters: {
        'incremental_output' : 'true' // 增量输出
      },
    }),
  }
)
if (!response.ok) {
  message.error('AI返回错误')
  loadingSend.value = false
  return
}

const decoder = new TextDecoder('UTF-8')
const reader = response.body.getReader()

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    console.log('流读取完成');
    // 中断fetch请求
    controller.abort()
    // 资源释放:释放读取器锁
    reader.releaseLock()
    break;
  }

  // 解码二进制数据为文本
  const chunk = decoder.decode(value, { stream: true })
  console.log('chunk:===>', chunk)
}

处理流式数据

通过 reader 读取到的数据经过 decoder 处理后格式如下:

id:1
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"我是","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":1,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

id:2
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"你的","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":2,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

当然这个是阿里云的返回格式,但流式数据格式都大差不差,接下来我们来分析这段文本。

首先,reader 获取的数据可能会有多段,如上文中的就是 id:1id:2 两段数据。

其中关键字段为:data.output.text ,所以我们需要根据返回数据的结构特点通过正则把有效信息给过滤出来。

// 全局贪婪匹配 "text":" 到 ","reject_status": 之间的内容,确保多段数据也能准确提取所有的有效信息
const regex = /"text":"(.*?)","reject_status":/gs;

这里使用正则而不是 JSON 化的原因是流式数据的处理讲究高效与准确JSON 化更加地消耗性能,而且存在异常报错的可能,为了最大可能保证主流程的持续输出,用正则是更优的选择。当然具体业务场景具体处理,这里仅作个人见解。

根据上述正则,实现一个数据处理函数:

const extractText = (jsonString) => {
  try {
    const regex = /"text":"(.*?)","reject_status":/gs;
    let match;
    let result = '';
    // 利用regex.exec()在字符串里循环查找所有匹配结果,把每次匹配得到的捕获组内容(也就是text字段的值)添加到result字符串中。
    while ((match = regex.exec(jsonString)) !== null) {
      // 将字符串里的\n转义序列转换为真正的换行符,把\"转义序列还原为普通的双引号。
      result += match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
    }
    return result
  } catch (error) {
    console.log('error', error)
    return ''
  }
}

最后把数据处理函数加到流式输出代码中,通过缓存持续获取有用的信息:

...
// 用于累计接收到的数据
let accumulatedText = ''

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    ...
    break;
  }

  const chunk = decoder.decode(value, { stream: true })
  // 累加并渲染数据
  const newText = extractText(chunk)
  if (newText) {
  accumulatedText += newText
  }
}

转换MD文本

这里用到几个库来实现:

  • markdown-it 一个快速、功能丰富的 Markdown 解析器,基于 JavaScript 实现。它的主要作用是把 Markdown 文本转换成 HTML。
  • @vscode/markdown-it-katex VS Code 团队开发的插件,用于在 Markdown 中渲染 LaTeX 数学公式,它集成了 KaTeX 这个快速的数学公式渲染引擎。
  • markdown-it-link-attributes 为 Markdown 中的链接添加自定义属性,比如为外部链接添加target="_blank"rel="noopener noreferrer"属性。
  • mermaid-it-markdown 用于在 Markdown 中集成 Mermaid 图表,Mermaid 是一种用文本语法描述图表的工具。

三方库使用

结合上述各种库结合,处理接口返回的信息流:

import MarkdownIt from 'markdown-it'
import MdKatex from '@vscode/markdown-it-katex'
import MdLinkAttributes from 'markdown-it-link-attributes'
import MdMermaid from 'mermaid-it-markdown'
import hljs from 'highlight.js'

const mdi = new MarkdownIt({
  html: false,
  linkify: true,
  highlight(code, language) {
    const validLang = !!(language && hljs.getLanguage(language))
    if (validLang) {
      const lang = language ?? ''
      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
    }
    return highlightBlock(hljs.highlightAuto(code).value, '')
  },
})
mdi.use(MdLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }).use(MdKatex).use(MdMermaid)

// 实现代码块快速复制
function highlightBlock(str, lang) {
  return `<pre class="code-block-wrapper">
            <div class="code-block-header">
                <span class="code-block-header__lang">${lang}</span>
                <span class="code-block-header__copy">复制代码</span>
            </div>
            <code class="hljs code-block-body ${lang}"><br>${str}</code>
          </pre>`
}

const renderToAI = (text) => {
  // 对数学公式进行处理,自动添加 $$ 符号
  const escapedText = escapeBrackets(escapeDollarNumber(text))
  return mdi.render(escapedText)
}

const escapeBrackets = (text) => {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock)
      return codeBlock
    else if (squareBracket)
      return `$$${squareBracket}$$`
    else if (roundBracket)
      return `$${roundBracket}$`
    return match
  })
}

const escapeDollarNumber = (text) => {
  let escapedText = ''

  for (let i = 0; i < text.length; i += 1) {
    let char = text[i]
    const nextChar = text[i + 1] || ' '

    if (char === '$' && nextChar >= '0' && nextChar <= '9')
      char = '\\$'

    escapedText += char
  }

  return escapedText
}

复制代码块

快速复制代码实现:

// 聊天列表主体元素
const textRef = ref()

// 构建textarea,将内容复制到剪切板
const copyToClip = (text) => {
  return new Promise((resolve, reject) => {
    try {
      const input = document.createElement('textarea')
      input.setAttribute('readonly', 'readonly')
      input.value = text
      document.body.appendChild(input)
      input.select()
      if (document.execCommand('copy'))
        document.execCommand('copy')
      document.body.removeChild(input)
      resolve(text)
    }
    catch (error) {
      reject(error)
    }
  })
}

// 为所有的复制代码按钮添加复制事件
const addCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.addEventListener('click', () => {
        const code = btn.parentElement?.nextElementSibling?.textContent
        if (code) {
          copyToClip(code).then(() => {
            btn.textContent = '复制成功'
            setTimeout(() => {
              btn.textContent = '复制代码'
            }, 1000)
          })
        }
      })
    })
  }
}

// 移除页面中所有的复制事件
const removeCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.removeEventListener('click', () => { })
    })
  }
}

// 在合适的生命周期里注册或卸载重新事件

// 可以在流式输出完成,页面渲染完成的时候手动调用,避免性能浪费,更加合理
onUpdated(() => {
  addCopyEvents()
})

onUnmounted(() => {
  removeCopyEvents()
})

自定义MD样式

MD样式:

.ai-message {
    background-color: transparent;
    font-size: 14px;
}
.ai-message p {
    white-space: pre-wrap;
}
.ai-message ol {
    list-style-type: decimal;
}
.ai-message ul {
    list-style-type: disc;
}
.ai-message pre code,
.ai-message pre tt {
    line-height: 1.65;
}
.ai-message .highlight pre,
.ai-message pre {
    background-color: #fff;
}
.ai-message code.hljs {
    padding: 0;
}
.ai-message .code-block-wrapper {
    position: relative;
    padding: 0 12px;
    border-radius: 8px;
}
.ai-message .code-block-header {
    position: absolute;
    top: 5px;
    right: 0;
    width: 100%;
    padding: 0 1rem;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    color: #b3b3b3;
}
.ai-message .code-block-header__copy {
    cursor: pointer;
    margin-left: 0.5rem;
    user-select: none;
}
.ai-message .code-block-header__copy:hover {
    color: #65a665;
}
.ai-message div[id^='mermaid-container'] {
    padding: 4px;
    border-radius: 4px;
    overflow-x: auto !important;
    background-color: #fff;
    border: 1px solid #e5e5e5;
}
.ai-message li {
    margin-left: 16px;
    box-sizing: border-box;
}

最后,把处理函数追加到处理流式数据后面:

let mdHtml = ''

...
const chunk = decoder.decode(value, { stream: true })
const newText = extractText(chunk)
if (newText) {
  accumulatedText += newText
  mdHtml += renderToAI(accumulatedText)
}

打字机

到目前为止我们已经流式地拿到了接口返回的数据并且转换成了页面可以展示的MD风格HTML字符串。

打字机的基本思路就是按照一定频率把内容添加到页面上,并且在内容最前面加个打字的光标。

直接上代码:

<template>
  <div v-html="displayText + `${ showCursor || adding ? `<span class='cursors'>_</span>`:'' }`"></div>
</template>

<script setup>
import { ref, watch, onUnmounted } from 'vue';

const props = defineProps({
  // 要显示的完整文本
  text: {
    type: String,
    required: true
  },
  // 打字速度(毫秒/字符)
  speed: {
    type: Number,
    default: 10
  },
  showCursor: {
    type: Boolean,
    default: false
  },
  break: {
    type: Boolean,
    default: false
  },
});
const emits = defineEmits(['update', 'ok'])

const displayText = ref('');
const adding = ref(false);
let timer = null;

// 更新显示的文本
const updateDisplayText = () => {
  if (displayText.value.length < props.text.length) {
    adding.value = true;
    displayText.value = props.text.substring(0, displayText.value.length + 1);
    emits('update')
    timer = setTimeout(updateDisplayText, props.speed);
  } else {
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
};

// 增量更新
watch(() => props.text, (newText) => {
  // 如果新文本比当前显示的文本长,则继续打字
  if (newText.length > displayText.value.length) {
    clearTimeout(timer);
    updateDisplayText();
  }
});

// 停止回答
watch(() => props.break, (val) => {
  if (val) {
    displayText.value = props.text + ''
    clearTimeout(timer);
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
});

// 初始化
updateDisplayText();

// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer);
});
</script>

<style>

.cursors {
  font-weight: 700;
  vertical-align: baseline;
  animation: blink 1s infinite;
  color: #3a5ccc;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
</style>  

我们只需要把上述转换的MD文本传入这个组件就能实现打字机效果。

<temlate>
<div class="ai-message">
    <TypingEffect :text="text" :showCursor="!ready" :break="break" @update="updateAIText" @ok="textAllShow" />
  </div>
</temlate>

需要注意的是,打字机打印的速度是按照恒定速度执行的,流式数据是不规则时间返回的,有可能返回很快,也有可能返回很慢,所以两边就会有时间差。

这就造成了一种现象,有时候我们点了停止回答的按钮,页面上还在不断输出内容,好像没有打断这次回答,这里我们只需要在点击停止回答的时候终止打字机的轮询,直接展示完整数据即可。

最后优化显示,需要自动滚动到底部:

const scrollToBottom = () => {
  try {
    const { height } = textRef.value.getBoundingClientRect()
    textRef.value.scrollTo({
      top: textRef.value.scrollHeight - height,
      behavior: 'smooth',
    })
  } catch (e) {}
}

总结

前端AI场景下总结来说就两个平时不常见的技术点:

  • 流式输出
  • 请求中断

当然本篇文章只是实现了基本的AI场景,像上下文记忆、多对话框以及更大模型的微调等等并未涉及到,这些更加深入地功能,可以后面慢慢研究,那么,这次就到这里~

Flutter AI 工具包:集成 AI 聊天功能到 Flutter App

作者 Bowen_Jin
2025年6月29日 21:00

image.png

主要特点

  1. 多轮聊天:自动维护聊天历史,保持多轮交互的语义连贯性
  2. 流式响应渲染:实时逐字显示 AI 回复,提升交互体验
  3. 富文本显示:支持 Markdown 解析、代码块高亮、链接识别等
  4. 语音输入:使用语音输入prompt。
  5. 多媒体输入:支持发送图片、文件等附件,AI 可识别处理
  6. 自定义样式:提供自定义样式,以匹配App设计。
  7. 聊天序列化/反序列化:存储和检索App会话之间的对话。
  8. 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  9. 可插拔LLM支持:实现一个简单的界面来插入自定义LLM。
  10. 跨平台支持:兼容Android、iOS、Web和macOS平台。

demo效果

image.png

源代码可在GitHub上找到

安装

依赖项添加到pubspec.yaml文件中

dependencies:
  flutter_ai_toolkit: ^latest_version
  google_generative_ai: ^latest_version # 使用Gemini
  firebase_core: ^latest_version        # 使用Firebase Vertex AI

Gemini AI配置

要使用Google Gemini AI,请从Google Gemini AI Studio获取API密钥

还需要选择一个Gemini model。

import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        body: LlmChatView(
          provider: GeminiProvider( // Gemini 服务提供商
            model: GenerativeModel( // Gemini model
              model: 'gemini-2.0-flash',
              apiKey: 'GEMINI-API-KEY', // Gemini API Key
            ),
          ),
        ),
      );
}

GenerativeModel类来自google_generative_ai软件包。GeminiProvider将Gemini AI插入到LlmChatView,LlmChatView是顶级Widget,与您的用户提供基于LLM的聊天对话。

Vertex AI configuration

另外一个AI服务是Firebase的Vertex AI。不需要API密钥,并用更安全的Firebase取代它。要在项目中使用Vertex AI,请按照 Get started with the Gemini API using the Vertex AI in Firebase SDKs 中描述的步骤进行操作。

完成后,使用flutterfire CLI工具将新的Firebase项目集成到您的Flutter App中,如Add Firebase to your Flutter app文档中所述。

按照这些说明操作后,您就可以在Flutter App中使用Firebase Vertex AI了。

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

// ... other imports

import 'firebase_options.dart'; // from `flutterfire config`

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const App());
}

在Flutter App中正确初始化Firebase后,可以创建Vertex provider的实例了:

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        // create the chat view, passing in the Vertex provider
        body: LlmChatView(
          provider: VertexProvider(
            chatModel: FirebaseVertexAI.instance.generativeModel(
              model: 'gemini-2.0-flash',
            ),
          ),
        ),
      );
}

FirebaseVertexAI类来自firebase_vertex ai软件包。构建VertexProvider类,将Vertex AI暴露给LlmChatView。不需要提供API密钥。这些都作为Firebase项目自动处理了。

LlmChatView

LlmChatView Widget 是AI Toolkit提供的互动聊天组件。支持如下功能

  • 多行文本输入:允许用户在输入prompt时粘贴长文本或插入新行。
  • 语音输入:允许用户使用语音输入prompt
  • 多媒体输入:允许用户拍照和发送图像和其他文件类型prompt。
  • 图像缩放:允许用户放大图像缩略图。
  • 复制到剪贴板:允许用户将消息或LLM响应的文本复制到剪贴板。
  • 消息编辑:允许用户编辑最新的消息以重新提交到LLM。
  • 支持Material 和 Cupertino两种设计样式

多行文本输入

语音输入

多媒体输入

图片缩放

点击能缩放图片

复制到剪贴板

文字编辑

长按文字, 弹出编辑菜单

支持Material and Cupertino两种设计样式

额外的功能

  • 欢迎信息:向用户显示初始问候。
  • prompt建议:向用户提供预定义的提建议prompt,以引导互动。
  • 系统指令:让 AI 系统明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。
  • 管理历史记录:每个LLM Provider都允许管理聊天记录,用于清除、动态更改和在会话之间存储聊天状态。
  • 聊天序列化/反序列化:存储和检索App会话之间的对话。
  • 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  • 自定义样式:定义独特的视觉样式,以将聊天外观与整个App相匹配。
  • 自定义LLM Provider:构建自定义LLM Provider,将聊天与您自己的模型后端集成。
  • 重新路由提示:调试、记录或重新路由消息,旨在让Provider动态跟踪问题或路由提示。

欢迎信息

自定义欢迎消息

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!', //初始化LlmChatView的欢迎消息:
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

prompt建议

没有聊天记录时,提供一组建议的prompt

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         suggestions: [
           'I\'m a Star Wars fan. What should I wear for Halloween?',
           'I\'m allergic to peanuts. What candy should I avoid at Halloween?',
           'What\'s the difference between a pumpkin and a squash?',
         ], /// 建议列表
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

系统指令

让 AI 明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。

例如,食谱示例App使用systemInstructions参数来定制LLM,以专注于根据用户的说明提供食谱:

class _HomePageState extends State<HomePage> {
  ...
  // create a new provider with the given history and the current settings
  LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
      history: history,
        ...,
        model: GenerativeModel(
          model: 'gemini-2.0-flash',
          apiKey: geminiApiKey,
          ...,
          systemInstruction: Content.system('''
You are a helpful assistant that generates recipes based on the ingredients and 
instructions provided as well as my food preferences, which are as follows:
${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences}

You should keep things casual and friendly. You may generate multiple recipes in a single response, but only if asked. ...
''', /// 系统指令
          ),
        ),
      );
  ...
}

历史记录管理

访问history属性查看或设置历史记录:

void _clearHistory() => _provider.history = [];

使用旧的历史来创建新的Provider:

class _HomePageState extends State<HomePage> {
  ...
  void _onSettingsSave() => setState(() {
        // 迁移旧的历史记录到新的供应商
        final history = _provider.history.toList();
        _provider = _createProvider(history);
      });
}

_createProvider方法创建了一个具有上一个Provider历史记录和新用户首选项的新Provider。这对用户来说是无缝的;他们可以继续聊天,但现在LLM会考虑他们的新食物偏好,给他们回复

class _HomePageState extends State<HomePage> {
  ...
  // 根据给定的历史记录和当前设置创建一个新的提供者
  LlmProvider _createProvider([List<ChatMessage>? history]) =>
    GeminiProvider(
      history: history,
      ...
    );
  ...
}

Chat序列化/反序列化

要在App会话之间保存和恢复聊天记录,需要能够对每个用户prompt(包括附件)和每个 LLM 响应进行序列化和反序列化。 两种消息(用户prompt和LLM响应)都暴露在ChatMessage类中。 序列化可以通过使用每个ChatMessage实例的toJson方法来完成。

Future<void> _saveHistory() async {
  // 获取最新的历史
  final history = _provider.history.toList();

  // 保存历史消息
  for (var i = 0; i != history.length; ++i) {
    // 文件存在旧忽略
    final file = await _messageFile(i);
    if (file.existsSync()) continue;

    // 新消息保存到磁盘
    final map = history[i].toJson();
    final json = JsonEncoder.withIndent('  ').convert(map);
    await file.writeAsString(json);
  }
}

同样,要反序列化,使用ChatMessage fromJson方法:

Future<void> _loadHistory() async {
  // 从磁盘读取历史记录
  final history = <ChatMessage>[];
  for (var i = 0;; ++i) {
    final file = await _messageFile(i);
    if (!file.existsSync()) break;

    final map = jsonDecode(await file.readAsString());
    history.add(ChatMessage.fromJson(map));
  }

  /// 设置历史记录
  _provider.history = history;
}

自定义响应Widget

默认聊天视图显示的 LLM 响应格式为 Markdown。可以创建一个自定义Widget来显示您的App风格的样式:

设置LlmChatView的responseBuilder参数:

LlmChatView(
  provider: _provider,
  welcomeMessage: _welcomeMessage,
  responseBuilder: (context, response) => RecipeResponseView(
    response,
  ),
),
class RecipeResponseView extends StatelessWidget {
  const RecipeResponseView(this.response, {super.key});
  final String response;

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[];
    String? finalText;

    // 收到LLM的回复后即时生成内容,因此目前无法得到完整的回复,添加一个按钮以便将食谱添加到列表中
    try {
      final map = jsonDecode(response);
      final recipesWithText = map['recipes'] as List<dynamic>;
      finalText = map['text'] as String?;

      for (final recipeWithText in recipesWithText) {
        // extract the text before the recipe
        final text = recipeWithText['text'] as String?;
        if (text != null && text.isNotEmpty) {
          children.add(MarkdownBody(data: text));
        }

        // 提取食谱
        final json = recipeWithText['recipe'] as Map<String, dynamic>;
        final recipe = Recipe.fromJson(json);
        children.add(const Gap(16));
        children.add(Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(recipe.title, style: Theme.of(context).textTheme.titleLarge),
            Text(recipe.description),
            RecipeContentView(recipe: recipe),
          ],
        ));

        // 添加按钮将食谱添加到列表中。
        children.add(const Gap(16));
        children.add(OutlinedButton(
          onPressed: () => RecipeRepository.addNewRecipe(recipe),
          child: const Text('Add Recipe'),
        ));
        children.add(const Gap(16));
      }
    } catch (e) {
      debugPrint('Error parsing response: $e');
    }

    ...

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: children,
    );
  }
}

自定义样式

使用LlmChatView构造函数的style参数来设置自己的样式,包括背景、文本字段、按钮、图标、建议等默认样式:

LlmChatView(
  provider: GeminiProvider(...),
  style: LlmChatViewStyle(...),
),

万圣节主题演示App

没有UI的聊天

不使用聊天视图也能访问Provider接口。

class _EditRecipePageState extends State<EditRecipePage> {
  ...
  final _provider = GeminiProvider(...);
  ...
  Future<void> _onMagic() async {
    final stream = _provider.sendMessageStream(
      'Generate a modified version of this recipe based on my food preferences: '
      '${_ingredientsController.text}\n\n${_instructionsController.text}',
    ); // 发送用户偏好食谱设置给llm provider
    var response = await stream.join(); // 获取llm推荐的响应
    final json = jsonDecode(response);

    try {
      final modifications = json['modifications'];
      final recipe = Recipe.fromJson(json['recipe']);

      if (!context.mounted) return;
      final accept = await showDialog<bool>( // 只使用了llm服务,没有使用聊天界面
        context: context,
        builder: (context) => AlertDialog(
          title: Text(recipe.title), // 推荐食谱标题
          content: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text('Modifications:'),
              const Gap(16),
              Text(_wrapText(modifications)), /// 修改的内容
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => context.pop(true),
              child: const Text('Accept'),
            ),
            TextButton(
              onPressed: () => context.pop(false),
              child: const Text('Reject'),
            ),
          ],
        ),
      );
      ...
    } catch (ex) {
      ...
      }
    }
  }
}

重新路由Prompt

设置LlmChatView messageSender来调试、记录或操作聊天视图和底层Provider之间的连接

class ChatPage extends StatelessWidget {
  final _provider = GeminiProvider(...);

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: const Text(App.title)),
      body: LlmChatView(
        provider: _provider,
        messageSender: _logMessage,
      ),
    );

  Stream<String> _logMessage(
    String prompt, {
    required Iterable<Attachment> attachments,
  }) async* {
    // log the message and attachments
    debugPrint('# Sending Message');
    debugPrint('## Prompt\n$prompt');
    debugPrint('## Attachments\n${attachments.map((a) => a.toString())}');

    // 发送消息到provider
    final response = _provider.sendMessageStream(
      prompt,
      attachments: attachments,
    );

    // log response信息
    final text = await response.join();
    debugPrint('## Response\n$text');

    yield text;
  }
}

用于一些高级操作,如动态路由到Provider或检索增强生成(RAG)。

定制LLM Provider

abstract class LlmProvider implements Listenable {
  Stream<String> generateStream(String prompt, {Iterable<Attachment> attachments});
  Stream<String> sendMessageStream(String prompt, {Iterable<Attachment> attachments});
  Iterable<ChatMessage> get history;
  set history(Iterable<ChatMessage> history);
}

任何实现LlmProvider接口的都可以插入聊天视图, 可以是云或本地的

  1. 提供配置支持
  2. 处理历史
  3. 将消息和附件翻译成底层LLM
  4. 调用底层LLM

配置支持

class GeminiProvider extends LlmProvider ... {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    ...
  })  : _model = model,
        ...

  final GenerativeModel _model;
  ...
}

处理历史

历史记录是Provider的重要组成部分

Provider不仅需要允许直接操作历史记录,而且必须在更改时通知Listener。 为了支持序列化和更改Provider参数,必须支持保存历史记录作为构建过程的一部分。

class GeminiProvider extends LlmProvider with ChangeNotifier {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    Iterable<ChatMessage>? history,
    ...
  })  : _model = model,
        _history = history?.toList() ?? [],
        ... { ... }

  final GenerativeModel _model;
  final List<ChatMessage> _history;
  ...

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]); /// 添加到历史记录

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  /// 获取当前的对话历史记录
  @override
  Iterable<ChatMessage> get history => _history;

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }

  ...
}
import 'package:google_generative_ai/google_generative_ai.dart';
...

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...
  static Part _partFrom(Attachment attachment) => switch (attachment) {
        (final FileAttachment a) => DataPart(a.mimeType, a.bytes),
        (final LinkAttachment a) => FilePart(a.url),
      };

  static Content _contentFrom(ChatMessage message) => Content(
        message.origin.isUser ? 'user' : 'model',
        [
          TextPart(message.text ?? ''),
          ...message.attachments.map(_partFrom),
        ],
      );
}

调用LLM

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...

  @override
  Stream<String> generateStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) =>
      _generateStream(
        prompt: prompt,
        attachments: attachments,
        contentStreamGenerator: (c) => _model.generateContentStream([c]),
      );

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  Stream<String> _generateStream({
    required String prompt,
    required Iterable<Attachment> attachments,
    required Stream<GenerateContentResponse> Function(Content)
        contentStreamGenerator,
  }) async* {
    final content = Content('user', [
      TextPart(prompt),
      ...attachments.map(_partFrom),
    ]);

    final response = contentStreamGenerator(content);
    yield* response
        .map((chunk) => chunk.text)
        .where((text) => text != null)
        .cast<String>();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }
}

最终的AI 聊天效果

新手必看,AI编程路上不可避免的Node管理

作者 李想AI
2025年6月27日 17:11

大家好,我是李想。

AI编程真的很火,但经常有朋友在问我Node是什么,经常看见运行这个项目需要Node环境,运行这个MCP又需要Node环境,问我怎么下载,下载哪个版本。

就比如新出的Gemini-cli,前置条件就是必须安装Node.js18的版本。

wechat_2025-06-27_151047_685.png

为了让小白更好的上手AI编程,今天我就给大家写一篇Node基础文章,并推荐一个Node的版本管理工具。

1.Node简介

那Node到底是什么?

wechat_2025-06-27_165300_332.png

官方介绍:Node.js 是一个利用高性能 V8 引擎在服务器端运行 JavaScript 的平台,其独特的事件驱动和非阻塞 I/O 设计使它成为构建高性能、可扩展网络应用的理想选择,并拥有极其丰富的 npm 生态系统支持。

其实不重要,你不需要掌握Node.js这门语言,只是因为有Node.js有着庞大的开源库生态系统 npm-它是世界上最大的软件注册中心,提供数百万个可重用的代码包(模块)

所以我们需要通过Node.js的 npm 去下载相关的依赖包(工具),这样我们才能更好的去接触AI编程,接触Github众多的项目。

2.下载安装

网址:nodejs.org/zh-cn

来到我们的官网,点击Install下载

图片.pngwechat_2025-06-27_165344_284.png

在这里你可以选择Node版本,你的系统,然后点击.msi开始下载

图片.pngwechat_2025-06-27_153704_231.png

下载完毕后运行msi文件,一直next。

图片.pngwechat_2025-06-27_153907_065.png

这里可以把Node安装到C盘以外的地方,然后一直next

wechat_2025-06-27_153957_706.png

这里点击Install就可以把Node安装到本地了。

图片.png

wechat_2025-06-27_154040_153.png

安装完毕后Win+R开启命令列,输入cmd打开终端。

图片.png

wechat_2025-06-27_154339_921.png

然后输入node -v和npm -v。

图片.pngwechat_2025-06-27_154520_986.png

成功显示版本信息就说明我们的Node安装成功了!

3.Node版本管理工具

给大家介绍了Node,实在有必要给大家说说Node的版本管理工具-nvm

网址:github.com/coreybutler…

wechat_2025-06-27_161112_751.png

为什么需要对Node版本进行管理呢?大家通过Node去下载项目依赖后会经常遇见项目启动报错,启动不起来。

很多时候就是因为Node版本的问题,因为有些项目他需要的Node版本可能是18。

有点项目又可能是22或者其他的,但是我们系统下载Node的版本只有一个,总不能跑一个相对就重新下一个Node版本吧。

这时候,Node版本管理工具nvm就很重要了,它可以下载多种node版本,在不同的项目中切换不同的Node版本,这样在下载项目的依赖就不会出错了!

网址:github.com/coreybutler…

来到我们的下载页面

图片.pngwechat_2025-06-27_161212_789.png

选择红框中的exe版本下载。在安装之前大家记得把之前下载的Node给卸载了,没安装过就不用管。

一直下一步,这里的路径记得不用使用中文。

图片.pngwechat_2025-06-27_161326_808.png

安装完毕后启动win+r,cmd启动命令输入nvm -v。

图片.png

wechat_2025-06-27_161600_008.png

成功显示就说明成功。

之后我们可以通过nvm install 来下载指定的Node版本,比如nvm install 18.20.7。

wechat_2025-06-27_162212_991.png

通过nvm list查看我们安装了哪些版本,比如我这里就显示了我下载了两个版本,*代表现在使用的是18.20.7版本。

图片.png

wechat_2025-06-27_162212_991.png

切换的时候同nvm use 23.0.0 就可以切换成功了。

图片.png

wechat_2025-06-27_162328_542.png

最后再附上命令一览表

命令 说明
nvm install <version> 安装指定版本的 Node.js
nvm install lts 安装最新的 LTS(长期支持)版本
nvm use <version> 切换使用指定的 Node.js 版本
nvm list 查看所有已安装的 Node.js 版本
nvm ls-remote 列出所有远程可用的 Node.js 版本
nvm uninstall <version> 卸载指定的 Node.js 版本
nvm alias default <version> 设置默认使用的 Node.js 版本
nvm current 显示当前正在使用的 Node.js 版本
nvm on 启用 nvm 版本管理功能
nvm off 禁用 nvm 版本管理功能
nvm version 查看当前安装的 nvm 版本
nvm proxy [url] 设置或查看下载代理服务器
nvm node_mirror [url] 设置或查看 Node.js 镜像源
nvm npm_mirror [url] 设置或查看 npm 镜像源
nvm reinstall-packages <ver> 将当前 npm 包重新安装到另一个 Node 版本
nvm list available 显示可供安装的所有版本(Windows 专用)
nvm root [path] 设置或查看 nvm 的安装路径
nvm cache dir 显示 nvm 的缓存目录路径
nvm cache clear 清空 nvm 的缓存

4.结语

今天的文章就到这里了,恭喜我们又掌握了编程路上的一个小知识。

图片.png

搭建自动化 Web 页面性能检测系统 —— AI 篇

2025年6月27日 10:55

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:琉易

这一篇是系列文章:

搭建自动化 Web 页面性能检测系统 —— 设计篇

搭建自动化 Web 页面性能检测系统 —— 实现篇

搭建自动化 Web 页面性能检测系统 —— 部署篇

页面性能对于用户体验、用户留存有着重要影响,当页面加载时间过长时,往往会伴随着一部分用户的流失,也会带来一些用户差评。性能的优劣往往是同类产品中胜出的影响因素,也是一个网站口碑的重要评判标准。

系统架构图(简略)

本篇重点讲解 AI 分析模块的设计与实践。

画板

AI 分析模块的设计与实现

输入与输出

  • 输入:Lighthouse 检查产生的 JSON 数据
    • 由于每次检测产生的 JSON 数据较大,一般有 350KB ~ 500KB,而大模型往往是根据输入 Tokens 进行计费,且并不是单纯的按量计费,类似于生活中常见的阶梯计费;另外模型支持的输入也有限,一般为 32k Tokens 或 64k Tokens。所以我们将 JSON 数据传输给大模型前需要进行精简。
    • 保留检测结果中的关键数据,如:环境数据(environment)、每一项检测指标的详细结果(audits)、检测时的配置参数(configSettings)、汇总各类指标的最终得分(categories)。
  • 输出:自然语言优化建议列表
    • 如:建议将图片资源启用 lazy-load
    • 如:减小某个图片文件的大小以减少传输时间

核心组成

  • JSON 清洗与摘要
  • Prompt 定义
  • openai 接口集成
  • 流式处理

Prompt 设计要点

构建一个高质量的 Prompt 是成功的关键,以下是一个例子:

你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。

实际测试中 Kimi 的 moonshot-v1-auto 模型回答更快,百炼平台的模型输入输出 Tokens 限制更宽泛,但是输出速度略慢;百炼平台的免费额度更多,OpenAI 费用较高且部署后会有访问的问题。

关键技术点

Lighthouse 报告数据结构解析

JSON 数据清洗与摘要是大模型调用能否成功的关键,清洗后的结果是 Prompt 的数据来源,如果内容较多可能会超出模型输入 Tokens 的限制从而导致调用失败。

  • audits 中会包含各种指标近百种,我们可以删除一些内容较多但对分析用处不大的数据,如:offscreen-images、screenshot-thumbnails 等。
  • Lighthouse 生成的 JSON 数据会直接保存瀑布图等图片的 Base64 格式数据,这些图片数据占用 Tokens 明显。

经过清洗,尽量将输入的 Tokens 控制在 100k 以内。

流式输出

openai 是一个 npm 包,通过这个 npm 包可以快速的对接各种大模型的 API 调用服务。

// 流式输出
const stream = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
    stream: true,
});

// 当启用流式输出模式(stream=True),SDK 返回的内容也发生了变化,我们不再直接访问返回值中的 choice
// 而是通过 for 循环逐个访问返回值中每个单独的块(chunk)
for await (const chunk of stream) {
    if (abortSignal?.aborted) {
        console.log(`${taskIdLogStr}任务中止, kimi chat`);
        break;
    }

    // 在这里,每个 chunk 的结构都与之前的 completion 相似,但 message 字段被替换成了 delta 字段
    const delta = chunk.choices[0].delta;
    if (delta.content) {
        onData(delta.content);
    }
}
@ApiOperation({ summary: '分析检测结果' })
    @HttpCode(HttpStatus.OK)
    @Post('reportChat')
    @RawResponse()
    async reportChat(@Body() query: ReportChatReqDto, @Res() res: Response) {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    const abortController = new AbortController();

    // 👇 监听客户端断开连接(如 Abort)
    res.on('close', () => {
        abortController.abort();
    });

    try {
        await this.AIService.reportChat(
            query,
            (content: string) => {
                res.write(`data: ${JSON.stringify({ content })}\n\n`);
            },
            abortController.signal
        );
        res.write(`data: [DONE]\n\n`);
    } catch (error) {
        res.write(`data: [ERROR] ${error.message || 'stream error'}\n\n`);
        abortController.abort();
    } finally {
        res.end();
    }
}

非流式输出

// 非流式输出
const completion = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能评分分析专家。我将提供一个产品的性能评分数据,帮我分析得分趋势和较大的得分变化。回答的内容不要带格式符号,尤其是 **。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
});

return completion.choices[0].message.content;

实现的功能点

检测报告的智能分析与建议

由于保存的是 html 文件,我们可以通过正则将 html 文件中的 JSON 数据提取出来,用于后续的清洗与分析。

image.png

结合清洗后的 JSON 数据给出优化建议。

数据周报的趋势分析

将过去一周的分数给到大模型,由大模型分析解读得分的变化趋势。

image.png

后续规划

  • JSON 数据清洗更精确,确定好哪些是关键性能指标
  • 将 Lighthouse 的具体评分规则同步给大模型
  • 优化 Prompt,更换更合适的大模型
  • 结合埋点数据
    • 分析页面性能与用户停留时长的关系
    • 分析用户跳出率与页面性能的关系
    • 结合采集到的埋点数据分析页面性能对业务指标的影响

如何让异步序列(AsyncSequence)优雅的感知被取消(Cancel)

2025年6月26日 11:23

在这里插入图片描述

概览

自  从 Swift 5.5 推出新的 async/await 并发模型以来,异步队列(AsyncSequence)就成为其中不可或缺的重要一员。

不同于普通的序列,异步序列有着特殊的“惰性”和并发性,若序列中的元素还未准备好,系统在耐心等待的同时,还将宝贵的线程资源供其它任务去使用,极大的提高了系统整体性能。

在本篇博文中,您将学到以下知识:

  1. 什么是异步序列?
  2. 创建自定义异步序列
  3. 另一种创建异步序列的方式:AsyncStream
  4. 取消异步序列的处理

什么是异步序列?

异步序列(AsyncSequence)严格的说是一个协议,它为遵守者提供异步的、序列的、迭代的序列元素访问。

在这里插入图片描述

表面看起来它是序列,实际上它内部元素是异步产生的,这意味着当子元素暂不可用时使用者将会等待直到它们可用为止: 在这里插入图片描述

诸多系统框架都对异步序列做了相应扩展,比如 Foundation 的 URL、 Combine 的发布器等等:

// Foundation
let url = URL(string: "https://kinds.blog.csdn.net/article/details/132787577")!
Task {
    do {
        for try await line in url.lines {
            print(line)
        }
    }catch{
        print("ERR: \(error.localizedDescription)")
    }
}

// Combine
let p = PassthroughSubject<Int,Never>()
for await val in p.values {
    print(val)
}

如上代码所示,URL#lines 和 Publisher#values 属性都是异步序列。

除了系统已为我们考虑的以外,我们自己同样可以非常方便的创建自定义异步序列。

创建自定义异步序列

一般来说,要创建自定义异步序列我们只需遵守 AsyncSequence 和 AsyncIteratorProtocol 协议即可:

在这里插入图片描述

下面我们就来创建一个“超级英雄们(Super Heros)”的异步序列吧:

struct SuperHeros: AsyncSequence, AsyncIteratorProtocol {
    
    private let heros = ["超人", "钢铁侠", "孙悟空", "元始天尊", "菩提老祖"]
    
    typealias Element = String
    var index = 0
    
    mutating func next() async throws -> Element? {
        defer { index += 1}
        
        try? await Task.sleep(for: .seconds(1.0))
        
        if index >= heros.count {
            return nil
        }else{
            return heros[index]
        }
    }
    
    func makeAsyncIterator() -> SuperHeros {
        self
    }
}

Task {
    let heros = SuperHeros()
    for try await hero in heros {
        print("出场英雄:\(hero)")
    }
}

以上异步序列会每隔 1 秒“产出”一名超级英雄:

在这里插入图片描述

如上代码所示,如果下一个超级英雄还未就绪,系统会在等待同时去执行其它合适的任务,不会有任何资源上的浪费。

另一种创建异步序列的方式:AsyncStream

其实,除了直接遵守 AsyncSequence 协议以外,我们还有另外一种选择:AsyncStream!

不像 AsyncSequence 和 AsyncIteratorProtocol 协议 ,AsyncStream 是彻头彻尾的结构(实体): 在这里插入图片描述

它提供两种构造器,分别供正常和异步序列产出(Spawning)情境使用:

public init(_ elementType: Element.Type = Element.self, bufferingPolicy limit: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded, _ build: (AsyncStream<Element>.Continuation) -> Void)

    
public init(unfolding produce: @escaping () async -> Element?, onCancel: (@Sendable () -> Void)? = nil)

下面为此举两个  官方提供的代码示例:

let stream_0 = AsyncStream<Int>(Int.self,
                    bufferingPolicy: .bufferingNewest(5)) { continuation in
     Task.detached {
         for _ in 0..<100 {
             await Task.sleep(1 * 1_000_000_000)
             continuation.yield(Int.random(in: 1...10))
         }
         continuation.finish()
    }
}

let stream_1 = AsyncStream<Int> {
    await Task.sleep(1 * 1_000_000_000)
    return Int.random(in: 1...10)
}

更多关于异步序列的知识,请小伙伴们移步如下链接观赏:


取消异步序列的处理

我们知道  新的 async/await 并发模型主打一个“结构化”,之所以称为“结构化”一个重要原因就是并发中所有任务都共同组成一个层级继承体系,当父任务出错或被取消时,所有子任务都会收到取消通知,异步序列同样也不例外。

就拿下面倒计时异步序列来说吧,它能感应父任务取消事件的原因是由于其中调用了 Task.sleep() 方法( sleep() 方法内部会对取消做出响应):

let countdown = AsyncStream<String> { continuation in
    Task {
        for i in (0...3).reversed() {
            try await Task.sleep(until: .now + .seconds(1.0), clock: .suspending)
            
            guard i > 0 else {
                continuation.yield(with: .success("🎉 " + "see you!!!"))
                return
            }
            
            continuation.yield("\(i) ...")
        }
    }
}

Task {
    for await count in countdown {
        print("current is \(count)")
    }
}

正常情况下,我们应该在异步序列计算昂贵元素之前显式检查 Cancel 状态:

let stream_1 = AsyncStream<Int> {
    // 假设 spawn() 是一个“昂贵”方法
    func spawn() -> Int {
        Int.random(in: 1...10)
    }
    
    // 或者使用 Task.checkCancellation() 处理异常
    if Task.isCancelled {
        return nil
    }
    
    return spawn()
}

在某些情况下,我们希望用自己的模型(Model)去关联退出状态,这时我们可以利用 withTaskCancellationHandler() 方法为异步序列保驾护航:

public func next() async -> Order? {
    return await withTaskCancellationHandler {
        let result = await kitchen.generateOrder()
        // 使用自定义模型中的状态来判断是否取消
        guard state.isRunning else {
            return nil
        }
        return result
    } onCancel: {
    // 在父任务取消时设置取消状态!
        state.cancel()
    }
}

注意,当父任务被取消时上面 onCancel() 闭包中的代码会立即执行,很可能和 withTaskCancellationHandler() 方法主体代码同步进行。

现在,在一些 Task 内置取消状态不适合或不及时的场合下,我们可以在异步序列中使用 withTaskCancellationHandler() 的 onCancel() 子句来更有效率的完成退出操作,棒棒哒!💯。

总结

在本篇博文中,我们首先简单介绍了什么是异步序列,接着学习了几种创建自定义异步序列的方法,最后我们讨论了如何优雅的取消异步序列的迭代。

感谢观赏,再会!8-)

❌
❌