阅读视图

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

经济学家:美国3月CPI月率或大涨1% 美联储今年或难降息

经济学家表示,美国消费者切身感受到的汽油价格突然上涨,将在本周公布的关键通胀数据中得到充分体现。预计美国3月CPI将环比上涨1%,这将是自2022年以来最大的单月涨幅;核心CPI可能环比上涨0.3%。此前伊朗战争推动美国加油站汽油价格每加仑上涨了约1美元。在CPI数据出炉的前一天,美联储青睐的通胀指标将提供战前价格压力的信息。经济学家预计,核心PCE价格指数在2月份可能连续第三个月上涨0.4%,这表明即使在冲突爆发之前,通胀回落至更温和水平的进程已经陷入停滞。结合美国劳动力市场企稳的迹象,顽固的价格压力以及中东战争带来的新通胀风险,有助于解释为什么美联储今年可能难以降低利率。

兆瓦级氢燃料航空涡桨发动机首飞成功

中国航发集团湖南动力机械研究所自主研制的兆瓦级氢燃料航空涡桨发动机AEP100配装7.5吨级无人运输机在株洲芦淞机场成功首飞。中国航发集团有关专家表示,未来随着绿氢制备成本的进一步下降,氢能航空动力的经济性优势和能源安全优势将逐步显现。氢燃料航空发动机技术有望率先在空中无人货运、海岛物流等低空经济领域展开应用,并逐步拓展至载人支线、干线飞机。这一技术将牵引上游绿色氢能制备、中游储运加注基础设施、下游高端装备与新材料等产业集群的协同升级,持续推动我国航空产业绿色低碳高质量发展。(财联社)

3月中国大宗商品价格指数环比上涨4%

中国物流与采购联合会今天公布3月份中国大宗商品价格指数。从指数运行情况看,节后企业生产稳步回升,市场需求较好释放,大宗商品市场总体保持扩张态势,向好基础进一步巩固。3月份中国大宗商品价格指数为129.9点,环比上涨4%,同比上涨14.5%,均好于去年同期水平。在中国物流与采购联合会重点监测的50种大宗商品中,3月价格环比上涨的大宗商品有38种,其中,柴油、甲醇和乙二醇涨幅居前,较上月环比分别上涨30.5%、30.4%和29.3%。(财联社)

高端智能投影品牌AWOL Vision获近亿元B轮融资,新品拿下近2000万美金众筹|硬氪首发

作者|黄楠

编辑|袁斯来

硬氪获悉,高端智能投影品牌AWOL Vision近日完成近亿元B轮融资,投资方包括天堂硅谷、会畅科技及金鹏佳等机构。资金将主要用于核心技术研发,持续迭代超短焦与长焦激光显示能力,并推进“家庭AI智能娱乐平台”生态构建;同时加快全球渠道与品牌拓展,强化供应链协同,并引入顶尖人才以夯实全球化运营基础。

AWOL Vision成立于2020年,长期专注RGB激光投影与家庭视听体验创新,并以深圳海高特科技有限公司作为其研发与设计中枢,为全球视听领域用户提供更具沉浸感的解决方案,重塑家庭娱乐体验。

3月31日,AWOL Vision新款超短焦4K三色激光投影产品Aetherion在Kickstarter上结束众筹,募得近2000万美元;加上2024年其子品牌Valerion的千万美元级项目,AWOL累计众筹金额已超2亿元,是首个立足中国、完成“双千万美元级验证”的消费电子品牌。

超短焦4K三色激光投影产品Aetherion(图源/企业)

从CRT到液晶,从单色激光到三色激光,家庭显示行业经历了持续的技术跃迁。市场竞争日趋成熟,但大多数品牌仍沿着一条从电视时代延续至今的硬件参数竞赛路径,不断追求更大尺寸、更高分辨率和更低价格。

在欧美等成熟市场,家庭影院正走向刚需。但一套传统高端系统的部署成本高达5万至6万美元,且一旦安装便几乎固定,迁移成本极高。同时,传统投影品牌智能化迭代滞后,缺乏无线投屏、自动对焦等基础体验。当硬件参数逼近极限,真正的差异化开始向交互体验迁移。

这正是AWOL Vision所切入的断层。海高特将原本用于专业影院的三色激光技术下放至消费级产品,并在两大品牌中AWOL Vision与Valerion形成了清晰的场景分工。AWOL主打超短焦技术,适用于明亮环境的客厅场景,支持开灯观看;Valerion则专注于长焦投影,服务于影音室、后院户外等暗光环境,强调影院级画质与氛围感。

适用于明亮环境的客厅场景(图源/企业)

公司在产品定义阶段便将“开箱即用”作为核心设计原则,其产品外形采用金属骨架与轻量化设计,便于用户在客厅、卧室、庭院等不同场景间移动;所有接口隐藏在盖板或凹槽内,避免线材外露。

使用过程中,设备支持自动对焦、自动梯形校正、自动画面避障和幕布对齐,即使用户挪动设备,画面也能实时保持最佳状态。

软件层面,预装Netflix、Disney+、HBO Max等主流流媒体平台,支持语音控制和一键投屏——这些在国内智能投影市场已成标配的功能,在海外高端投影赛道中仍是稀缺能力。

可以看到,凭借独特的“美国品牌 +中国设计中心”模式,海高特依托中国成熟的智能硬件研发与供应链优势,瞄准欧美传统投影市场,提供更完整的使用体验与场景化解决方案,显著降低了高端家庭影院的实际使用门槛。

而随着设备进入越来越多家庭,一个更核心的问题浮现:如何让投影从“被动显示工具”进化为“主动理解用户的娱乐伙伴”?

家庭娱乐的竞争正在从“屏的竞争”转向“入口的竞争”。电视、投影、AR/VR设备都在争夺用户家庭中的核心交互位置。然而,当前市场上大部分投影设备仍停留在被动显示阶段,即用户选择内容、设备播放,交互止步于遥控器或语音指令。

构建家庭AI智能娱乐平台(图源/企业)

家庭娱乐的需求正在深化:用户不再满足于单次观影,而是希望设备能够理解使用习惯、预测内容偏好、自动适配不同场景。周末晚上自动切换为电影模式并调暗环境光,游戏模式下自动优化延迟与音效联动等,这些能力的背后,需要一个持续学习用户行为的AI引擎。

这正是AWOL Vision的差异化所在。它并非从零搭建一个操作系统,而是基于用户最高频的观影和游戏场景,逐步叠加环境感知、多设备协同、个性化推荐等AI能力。这种“硬件+场景+AI”的渐进式路径,更易被用户感知和接受。

到这时,智能投影设备将不再只是一块负责播放的终端,更成为连接内容、环境、设备与用户习惯的AI智能娱乐入口,在下一代智能生态竞争中成为建立长期壁垒的关键。

投资方观点:

天堂硅谷表示,AWOL Vision在全球最挑剔的消费市场持续验证了产品力,两次众筹破纪录是真实市场需求最有力的证明。我们看好其从高端硬件向平台生态延伸的战略路径,这是一家有潜力在全球家庭娱乐赛道诞生世界级品牌的企业。

会畅科技表示,作为产业投资方,我们与AWOL Vision在视听技术与内容生态层面有高度互补的战略协同空间。作为合作伙伴,我们期待与AWOL共同定义下一代家庭沉浸式娱乐的标准。

使用 AI SDK 创建 「知识库」

rag-workflow.png

今天分享一个用纯 Node.js 实现知识库(RAG)的最简方案。

RAG 的核心思路其实并不复杂:

  • 对文档进行内容分割,将文档拆解成一个个小的语义分块(chunk);
  • 将这些分块通过大模型解析成向量(embedding)并储存在向量数据库中;
  • 当用户输入一个查询时,同样将查询进行向量化处理,通过向量数据库检索高度相关的知识片段;
  • 最后将这些片段整合到发送给大模型的上下文中,提升回答的精准度和相关性。

开始

技术栈:

  • libSQL - 向量数据库
  • AI SDK - 大模型调用与向量解析

安装依赖

npm install @libsql/client @ai-sdk/openai-compatible ai dotenv

添加环境变量

先在项目根目录创建 .env

AIPROXY_API_KEY=your_api_key_here

初始化

先把最基础的依赖准备好,包括模型客户端、本地数据库客户端,以及一组演示知识。

import 'dotenv/config'
import { createClient } from '@libsql/client/sqlite3'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { embed, generateText } from 'ai'

const aiproxy = createOpenAICompatible({
  baseURL: 'https://api.aiproxy.shop/v1',
  apiKey: process.env.AIPROXY_API_KEY!,
  name: 'aiproxy',
})

const db = createClient({
  url: 'file:local.db',
})

const knowledgeDocuments = [
  {
    title: 'AI SDK 是什么',
    content:
      'AI SDK 是一个帮助 TS 和 JS 开发者快速接入大模型的工具包,支持流式响应、工具调用和多模型适配。',
  },
  {
    title: 'RAG 的核心流程',
    content:
      'RAG 的核心流程是切分文档、生成向量、保存向量、查询时把问题也转成向量、最后检索最相近的内容作为上下文。',
  },
  {
    title: '为什么要做分块',
    content:
      '因为整篇文档太长会影响检索精度,所以通常要先按语义切成多个 chunk,再分别生成 embedding。',
  },
]

type StoredChunk = {
  id: number
  title: string
  content: string
  distance: number
}

文档分块

RAG 不会直接拿整篇文档做检索,而是将文档拆分成很多小的文本块。

const splitIntoChunks = (text: string, size = 200) => {
  const normalized = text.replace(/\s+/g, ' ').trim()
  if (!normalized) {
    return []
  }

  const chunks: string[] = []
  for (let index = 0; index < normalized.length; index += size) {
    chunks.push(normalized.slice(index, index + size))
  }

  return chunks
}

如何生成 chunk 的内容?

最简单的办法就是直接按字符切分。比如上面 size = 200,就表示每 200 个字符切成一个块。或者可以通过标点符号进行切分,也可以用一些第三方库来进行智能分割。

生成 Embedding

这里会在两个阶段使用 embedding:

  1. 入库时把每个 chunk 转成向量并存储。
  2. 查询时把用户问题也转成向量,用于检索最相关的上下文。
const createEmbedding = async (value: string) => {
  const result = await embed({
    model: aiproxy.embeddingModel('openai/text-embedding-3-small'),
    value,
  })

  return result.embedding
}

初始化知识库表

现在要把知识片段真正保存到本地数据库中。

const initializeDatabase = async () => {
  await db.execute(`
    CREATE TABLE IF NOT EXISTS documents (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      content TEXT NOT NULL,
      embedding BLOB NOT NULL,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `)
}

这里的设计也比较直接:

  1. documents 表存标题、文本内容和 embedding。
  2. embedding 用 BLOB 存储,方便直接使用 libSQL 的向量函数检索。

这不是最终架构里性能最高的方案,但它非常利于你先理解“存储 + 检索”的闭环。

知识库入库

有了表结构之后,我们还需要把知识内容写入数据库。

const seedKnowledgeBase = async () => {
  await initializeDatabase()

  const countResult = await db.execute('SELECT COUNT(*) AS count FROM documents')
  const count = Number(countResult.rows[0]?.count ?? 0)

  if (count > 0) {
    return
  }

  for (const document of knowledgeDocuments) {
    const chunks = splitIntoChunks(document.content)

    for (const chunk of chunks) {
      const embedding = await createEmbedding(chunk)
      const embeddingBuffer = Buffer.from(new Float32Array(embedding).buffer)

      await db.execute({
        sql: 'INSERT INTO documents (title, content, embedding) VALUES (?, ?, ?)',
        args: [document.title, chunk, embeddingBuffer],
      })
    }
  }
}

入库流程如下:

  1. 如果表里已经有数据,就不重复写入;
  2. 把每篇文档切成多个 chunk;
  3. 给每个 chunk 生成 embedding;
  4. 把 chunk 和向量一起存进数据库。

检索相关片段

当用户提问时,我们先把问题转成向量,然后直接在数据库里做 Top-K 检索。

const searchKnowledge = async (query: string, limit = 3) => {
  const userQueryEmbedded = await createEmbedding(query)
  const queryBuffer = Buffer.from(new Float32Array(userQueryEmbedded).buffer)

  const rs = await db.execute({
    sql: `
      SELECT
        id,
        title,
        content,
        vector_distance_cos(embedding, vector32(?)) AS distance
      FROM documents
      ORDER BY distance ASC
      LIMIT ?
    `,
    args: [queryBuffer, limit],
  })

  return rs.rows.map((row) => ({
    id: Number(row.id),
    title: String(row.title),
    content: String(row.content),
    distance: Number(row.distance),
  }))
}

查询流程如下:

  1. 先把用户问题转成 embedding。
  2. vector32(?) 把查询向量传给数据库。
  3. 数据库内部用 vector_distance_cos 计算距离并排序,拿到 Top-K 结果。

封装主函数

最后,把上面所有逻辑串起来。

export const chatWithKnowledge = async (question: string) => {
  await seedKnowledgeBase()

  const results = await searchKnowledge(question)
  const contextText = results.map((item) => `- ${item.title}: ${item.content}`).join('\n')

  const result = await generateText({
    model: aiproxy('deepseek/deepseek-chat'), 
    system: `你是一个有用的助手。请优先根据知识库内容回答问题;如果知识库里没有相关信息,就明确告诉用户你不知道。\n\n知识库上下文:\n${contextText}`,
    prompt: question,
  })

  return {
    answer: result.text,
    references: results,
  }
}

整个请求链路到这里就闭环了:

  1. 函数执行时,先确保知识库已经初始化;
  2. 把用户问题转成向量;
  3. 在数据库层执行向量检索,拿到最相关片段;
  4. 把这些片段加入 system prompt,作为上下文或者参考信息;
  5. 调用主模型生成最终回答;
  6. 返回最终答案和命中的参考片段。

把上面所有代码拼到一起,就是一个完整的单文件 RAG Demo。

在文件末尾加上入口调用,直接运行即可:

const result = await chatWithKnowledge('RAG 为什么要分块?')

console.log('回答:', result.answer)
console.log('参考片段:', result.references)

知识库文档处理

在上面的示例里,我直接在代码里写了一个 knowledgeDocuments 数组来模拟知识库内容:

const knowledgeDocuments = [
  {
    title: '你的第一篇文档',
    content: '这里放你自己的知识内容',
  },
]

如果你的内容来自 Markdown、数据库或者 CMS,也可以先把内容读出来,再复用同样的 splitIntoChunks -> createEmbedding -> insert 流程。如果你的文档是 PDF 或图片,也可以先用 OCR 或者 pdf.js 把它们转成文本,再进行后续处理。

运行

npx tsx rag.ts

结语

复杂的应用是一个个小的应用组合起来的,学会每个小的知识点,就能够构建非常牛x的大型项目。

大家可以看一下我实现的 知识库的 NPM CLI 工具 - Meow ,欢迎 star 🌟。

Agent 工程化 的核心

当前 Agent 工程化 的核心。我通过一个完整的代码示例,把它们串起来讲清楚。


一、整体架构图(先有个印象)

text

用户输入
   │
   ▼
┌─────────────────────────────────────────────┐
│                    Agent                      │
│  ┌─────────────────────────────────────────┐ │
│  │           历史消息 (Messages)            │ │
│  │  [{role:user, content}, {role:assistant}]│ │
│  └─────────────────────────────────────────┘ │
│                    │                          │
│  ┌─────────────────────────────────────────┐ │
│  │            工作流 (Workflow)             │ │
│  │  Plan → Execute → Observe → Loop        │ │
│  └─────────────────────────────────────────┘ │
│                    │                          │
│  ┌──────────────┬──────────────┐             │
│  │  工具调用    │   子Agent     │    Skills  │
│  │  (Tools)     │ (Sub-Agent)   │  (能力集)  │
│  └──────────────┴──────────────┘             │
└─────────────────────────────────────────────┘

二、完整代码示例(可直接运行)

用 TypeScript + Bun 实现一个能做数学计算和天气查询的简单 Agent:

typescript

// agent.ts - 一个完整的 Agent 实现

// ==================== 1. 历史消息管理 ====================
interface Message {
  role: 'user' | 'assistant' | 'tool';
  content: string;
  toolCallId?: string;
  timestamp: number;
}

class MessageHistory {
  private messages: Message[] = [];
  private maxTokens: number = 4000;

  add(message: Message) {
    this.messages.push(message);
    this.trimIfNeeded();
  }

  get() {
    return this.messages;
  }

  getForLLM() {
    // 返回 LLM 需要的格式,只保留最近的消息
    return this.messages.slice(-20).map(m => ({
      role: m.role,
      content: m.content
    }));
  }

  private trimIfNeeded() {
    // 简化版:超过 50 条就删除一半
    if (this.messages.length > 50) {
      this.messages = this.messages.slice(-25);
    }
  }
}

// ==================== 2. 工具定义与调用 ====================
interface Tool {
  name: string;
  description: string;
  parameters: Record<string, any>;
  execute: (args: any) => Promise<string>;
}

// 工具1:计算器
const calculatorTool: Tool = {
  name: 'calculator',
  description: '执行数学计算,支持 + - * / 和 sqrt',
  parameters: {
    type: 'object',
    properties: {
      expression: { type: 'string', description: '数学表达式,如 "2+3*4"' }
    },
    required: ['expression']
  },
  execute: async (args) => {
    try {
      // 安全计算(生产环境请用 math.js 等库)
      const result = eval(args.expression);
      return `计算结果: ${result}`;
    } catch (e) {
      return `计算错误: ${e.message}`;
    }
  }
};

// 工具2:模拟天气查询
const weatherTool: Tool = {
  name: 'get_weather',
  description: '查询指定城市的天气',
  parameters: {
    type: 'object',
    properties: {
      city: { type: 'string', description: '城市名称' }
    },
    required: ['city']
  },
  execute: async (args) => {
    // 模拟 API 调用
    const weathers = {
      '北京': '晴天 25°C',
      '上海': '多云 22°C',
      '深圳': '阵雨 28°C'
    };
    return weathers[args.city] || `${args.city} 天气: 晴 20°C`;
  }
};

// ==================== 3. 子 Agent(专门处理特定任务)====================
class SubAgent {
  name: string;
  description: string;
  private handler: (input: string) => Promise<string>;

  constructor(name: string, description: string, handler: (input: string) => Promise<string>) {
    this.name = name;
    this.description = description;
    this.handler = handler;
  }

  async run(input: string): Promise<string> {
    console.log(`  [子Agent:${this.name}] 处理: ${input}`);
    return this.handler(input);
  }
}

// 创建两个子 Agent
const mathSubAgent = new SubAgent(
  'math-expert',
  '专门处理复杂数学问题',
  async (input) => {
    // 模拟复杂计算
    await Bun.sleep(500); // 假装在计算
    return `【数学专家】计算结果: ${input.replace('计算', '').trim()} = 42`;
  }
);

const weatherSubAgent = new SubAgent(
  'weather-expert', 
  '专门处理天气相关问题',
  async (input) => {
    await Bun.sleep(300);
    const city = input.match(/[北京上海深圳广州]+/)?.[0] || '未知';
    return `【天气专家】${city},温度适中,建议出门带伞`;
  }
);

// ==================== 4. Skill(可复用的能力模块)====================
interface Skill {
  name: string;
  description: string;
  execute: (context: any) => Promise<any>;
}

const loggingSkill: Skill = {
  name: 'logging',
  description: '记录 Agent 的执行日志',
  execute: async (context) => {
    console.log(`[LOG] ${new Date().toISOString()} - ${context.action}`);
    return { logged: true };
  }
};

const memorySkill: Skill = {
  name: 'memory',
  description: '记住用户的重要偏好',
  execute: async (context) => {
    // 简化版:存到全局 Map
    if (context.preference) {
      userPreferences.set(context.userId, context.preference);
    }
    return { remembered: true };
  }
};

const userPreferences = new Map<string, any>();

// ==================== 5. 主 Agent(核心工作流)====================
class SimpleAgent {
  private tools: Map<string, Tool> = new Map();
  private subAgents: Map<string, SubAgent> = new Map();
  private skills: Skill[] = [];
  private messageHistory: MessageHistory;

  constructor() {
    this.messageHistory = new MessageHistory();
    this.registerDefaultTools();
  }

  // 注册工具
  registerTool(tool: Tool) {
    this.tools.set(tool.name, tool);
    console.log(`📦 注册工具: ${tool.name}`);
  }

  // 注册子 Agent
  registerSubAgent(agent: SubAgent) {
    this.subAgents.set(agent.name, agent);
    console.log(`🤖 注册子Agent: ${agent.name}`);
  }

  // 注册 Skill
  registerSkill(skill: Skill) {
    this.skills.push(skill);
    console.log(`⚡ 注册Skill: ${skill.name}`);
  }

  private registerDefaultTools() {
    this.registerTool(calculatorTool);
    this.registerTool(weatherTool);
    this.registerSubAgent(mathSubAgent);
    this.registerSubAgent(weatherSubAgent);
    this.registerSkill(loggingSkill);
    this.registerSkill(memorySkill);
  }

  // ========== 核心工作流 ==========
  async run(userInput: string): Promise<string> {
    console.log('\n' + '='.repeat(50));
    console.log(`📝 用户: ${userInput}`);
    console.log('='.repeat(50));

    // Step 1: 添加用户消息到历史
    this.messageHistory.add({
      role: 'user',
      content: userInput,
      timestamp: Date.now()
    });

    // Step 2: 意图识别(简化版,实际应该用 LLM)
    const intent = this.analyzeIntent(userInput);
    console.log(`🎯 识别意图: ${intent.type}`);

    // Step 3: 执行 Skills(前置)
    for (const skill of this.skills) {
      await skill.execute({ action: intent.type, userId: 'default' });
    }

    // Step 4: 根据意图分发处理
    let result: string;
    
    if (intent.type === 'calculation' && intent.tool) {
      // 直接调用工具
      result = await this.callTool(intent.tool, intent.args);
    } 
    else if (intent.type === 'weather') {
      // 可以调用工具或子 Agent,这里演示委托给子 Agent
      result = await this.delegateToSubAgent('weather-expert', userInput);
    }
    else if (intent.type === 'complex_math') {
      result = await this.delegateToSubAgent('math-expert', userInput);
    }
    else {
      // 普通对话
      result = await this.generateResponse(userInput);
    }

    // Step 5: 保存助手回复到历史
    this.messageHistory.add({
      role: 'assistant',
      content: result,
      timestamp: Date.now()
    });

    console.log(`🤖 助手: ${result}`);
    return result;
  }

  // 意图分析(简化版,实际应该调用 LLM)
  private analyzeIntent(input: string): {
    type: 'calculation' | 'weather' | 'complex_math' | 'chat';
    tool?: string;
    args?: any;
  } {
    // 计算器意图
    if (input.includes('+') || input.includes('-') || input.includes('*') || input.includes('/') || input.includes('计算')) {
      const match = input.match(/[\d\s+-*/()]+/);
      if (match && match[0].trim()) {
        return { type: 'calculation', tool: 'calculator', args: { expression: match[0] } };
      }
    }
    
    // 天气意图
    if (input.includes('天气')) {
      return { type: 'weather' };
    }
    
    // 复杂数学
    if (input.includes('方程') || input.includes('积分') || input.includes('导数')) {
      return { type: 'complex_math' };
    }
    
    return { type: 'chat' };
  }

  // 调用工具
  private async callTool(toolName: string, args: any): Promise<string> {
    const tool = this.tools.get(toolName);
    if (!tool) return `工具 ${toolName} 不存在`;
    
    console.log(`🔧 调用工具: ${toolName}`, args);
    return await tool.execute(args);
  }

  // 委托给子 Agent
  private async delegateToSubAgent(agentName: string, input: string): Promise<string> {
    const agent = this.subAgents.get(agentName);
    if (!agent) return `子Agent ${agentName} 不存在`;
    
    console.log(`🔄 委托给子Agent: ${agentName}`);
    return await agent.run(input);
  }

  // 生成回复(简化版,实际应该调用 LLM)
  private async generateResponse(input: string): Promise<string> {
    if (input.includes('你好') || input.includes('嗨')) {
      return '你好!我是智能助手,可以帮你计算、查天气等。试试说"计算 2+3"或"北京天气"';
    }
    return `收到: "${input}"。我是一个简单Agent,能处理计算和天气查询。`;
  }

  // 查看历史消息
  showHistory() {
    console.log('\n📜 历史消息:');
    for (const msg of this.messageHistory.get()) {
      console.log(`  [${msg.role}] ${msg.content.slice(0, 50)}`);
    }
  }
}

// ==================== 6. 运行演示 ====================
async function main() {
  console.log('🚀 启动 Simple Agent...\n');
  
  const agent = new SimpleAgent();
  
  console.log('\n' + '🌟 Agent 已就绪,开始对话...\n');
  
  // 测试各种场景
  await agent.run('你好,你是谁?');
  await agent.run('计算 15 + 27');
  await agent.run('北京天气怎么样?');
  await agent.run('帮我解方程 x^2 = 4');
  
  // 查看历史消息
  agent.showHistory();
  
  console.log('\n✅ 演示完成');
}

// 运行
main().catch(console.error);

三、用 Bun 运行

bash

# 安装 bun(如果还没装)
curl -fsSL https://bun.sh/install | bash

# 运行 Agent
bun run agent.ts

输出示例:

text

🚀 启动 Simple Agent...
📦 注册工具: calculator
📦 注册工具: get_weather
🤖 注册子Agent: math-expert
🤖 注册子Agent: weather-expert
⚡ 注册Skill: logging
⚡ 注册Skill: memory

==================================================
📝 用户: 计算 15 + 27
==================================================
🎯 识别意图: calculation
[LOG] 2026-04-04T10:30:00.000Z - calculation
🔧 调用工具: calculator { expression: "15+27" }
🤖 助手: 计算结果: 42

==================================================
📝 用户: 北京天气怎么样?
==================================================
🎯 识别意图: weather
[LOG] 2026-04-04T10:30:01.000Z - weather
🔄 委托给子Agent: weather-expert
  [子Agent:weather-expert] 处理: 北京天气怎么样?
🤖 助手: 【天气专家】北京,温度适中,建议出门带伞

四、核心概念对照表

概念 在这个例子中的体现 作用
历史消息 MessageHistory 类 保留对话上下文,支持多轮交互
工作流 run() 方法中的 5 个步骤 意图识别→技能执行→工具/子Agent→返回结果
工具调用 calculatorToolweatherTool Agent 通过工具执行具体操作
子 Agent mathSubAgentweatherSubAgent 专门化处理,可以嵌套调用
Skill loggingSkillmemorySkill 可复用的横切能力,可在工作流中自动执行
Bun 一体化 一个文件搞定 TypeScript 编译+运行 不需要 tsc + node,直接 bun run

五、关于 Bun 的亮点

你说得对,Bun 的运行时+构建一体化确实很棒:

bash

# 传统 Node + TypeScript 需要:
npm install -g typescript ts-node
tsc agent.ts && node agent.js

# Bun 只需要:
bun run agent.ts  # 直接运行,自动编译

Bun 还内置了:

  • 包管理器(比 npm/yarn/pnpm 快很多)
  • 测试运行器
  • 打包器(bun build)
  • 原生支持 JSX、TS

运行这个例子后,你会直观感受到一个 Agent 是如何组织起来的。想深入了解哪一块?比如:

  • 如何接入真实的 LLM(OpenAI/Claude API)?
  • 如何处理更复杂的工作流(循环、重试、并行)?
  • 工具调用的 function calling 具体怎么对接?

Vue3.5设计理念和响应式原理(下)

computed 实现原理

// 实例
const state = reactive({ name: "zoyi" });

const aliasName = computed(() => {
  console.log("getter 执行");
  return "**" + state.name;
});

effect(() => {
  console.log("外层 effect 执行");
  console.log(aliasName.value);
});

state.name = "star zoyi";

初始化

  1. 执行到 computed(getter) 时,返回ComputedRefImpl(getter)实例 aliasName:创建内部的 ReactiveEffect(getter, scheduler);实例(aliasName).value 是可 get/set 的响应式。
export class ComputedRefImpl {
  constructor(getter: () => any) {
    this.effect = new ReactiveEffect(getter, () => {
      //...
    });
  }
}
  1. 执行 effect(fn),创建外层 effect 实例,将 fn 添加至 schedule 中并执行。
  2. 打印 外层 effect 执行。 执行 aliasName.value ===> 触发内部 effect 的 get value。
  3. 在 getter 若有 activeEffect(外部 effect.run() 时保存的 activeEffect),把外层 effect 记进 aliasName.dep。
get value() {
  // 外层 effect 读取计算属性时,把外层 effect 记到本 ref 的 dep 上
  this.trackComputed();
  if (this._dirty === DirtyLevels.Dirty) {
    this._dirty = DirtyLevels.NoDirty;
    this._value = this.effect.run();
  }
  return this._value;
}

/** 收集「谁依赖了这个计算属性」 */
  private trackComputed() {
    if (!activeEffect) {
      return;
    }
    this.dep ??= createDep(() => {
      this.dep = undefined;
    }, "computed");
    trackEffect(activeEffect, this.dep);
  }
  1. 第一次 _dirty 默认是脏,改为不脏,并执行 内部 effect.run()(即包含 computed 的 getter方法的运行器)。
  2. 更新 activeEffect 为内部 effect,执行 getter,打印 getter 执行return 中执行 state.name 触发 name 属性的 get,将此时 activeEffect = 内层effect,收集为依赖。返回 name = zoyi
  3. getter 中 return 计算后属性 @zoyi,将值缓存到 aliasName._value 上,aliasName.value 的 get value 执行完毕,并返回 _value
  4. 打印 @zoyi,外层 effect.run() 执行完毕。

此时关系是

state.name 的 dep → 内层 ReactiveEffect(计算属性的 scheduler)。 aliasName.dep → 外层 effect(读了 .value)。

更新阶段(Vue 3.4)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。

    set(target, key, value, recevier) {
     let oldValue = target[key];
     let result = Reflect.set(target, key, value, recevier);
    
     // 只有新旧值不一样才会触发更新
     if (oldValue !== value) {
       trigger(target, key, value, oldValue);
     }
    
     return result;
    }
    
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。

    • 但默认不会执行 run,只把 _dirty 设置为脏。
    • triggerEffects(aliasName.dep) → 外层 effect 的 scheduler 执行 → 外层 effect 再次 run()。
constructor(getter: () => any) {
  // 不在此构造函数里立即 run:首次访问 .value 时再求值,实现惰性。
  // scheduler:依赖变更时不立刻重算,只标脏并通知「读过我的人」去更新。
  this.effect = new ReactiveEffect(getter, () => {
    if (this._dirty === DirtyLevels.NoDirty) {
      this._dirty = DirtyLevels.Dirty;
    }
    if (this.dep) {
      triggerEffects(this.dep); // aliasName.dep
    }
  });
}
  1. 打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 发现 _dirty 为脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value,再标不脏。
  2. 打印 @star zoyi,结束更新

3.4版本

注意:在 Vue 3.5 中 computed 的更新阶段稍微有些变化

更新阶段(Vue 3.5)

  1. 执行 state.name = "star zoyi" state.name 发生改变,触发 name 的 setter。
  2. 新旧值不一样,触发 trigger,执行收集到的内层 effect 的 scheduler。
  3. 此时发生了变化: 执行 refreshComputed -> 发现 _dirty 为脏,先清脏 → 执行 this.effect.run() → 打印 getter 执行,读到新 state.name,得到 @star zoyi,缓存进 _value
constructor(getter: () => any) {
  this.effect = new ReactiveEffect(getter, () => {
    // 3.5 风格:先置脏并同步重算,再通知下游(顺序与官方包一致)
    this._dirty = DirtyLevels.Dirty;
    this.refreshComputed();
    if (this.dep) {
      triggerEffects(this.dep); // 再执行外层 effect.run
    }
  });
}

/**
 * 若当前为脏,则执行内层 effect(getter),更新 _value 并清脏。
 */
private refreshComputed() {
  if (this._dirty !== DirtyLevels.Dirty) {
    return;
  }
  this._dirty = DirtyLevels.NoDirty;
  this._value = this.effect.run(); // 先执行 getter
}
  1. 再执行外层 effect.run,打印 外层 effect 执行,执行到 aliasName.value,再次进入其 get value 中。
    • trackComputed() 再次把外层 effect 记到 aliasName.dep(去重逻辑在 trackEffect 里)。
    • 已经计算过新的属性了,直接从 _value 中获取并返回。
  2. 打印 @star zoyi,结束更新。
get value() {
  // 收集计算属性(aliasName)的依赖,再保证缓存最新
  this.trackComputed();
  this.refreshComputed(); // _dirty 为不脏直接返回
  return this._value; // 已经计算过新的属性了,直接从_value中获取
}

3.5版本

watch 实现原理

watch(
  { state.name }, // source
  (prev, next, onCleanup) => { //cb
    console.log("触发回调函数")

    onCleanup(() => {
      console.log("清理副作用函数");
    });
  },
  {
    immediate: false, // 立即执行一次
    deep: false // 是否深度监听
  });

source 发生变化,触发 cb 的执行

即 watch 需要实现:完成 source (必须是响应式)对某个 effect 进行收集,在触发 scheduler 时,将 cb 加入到其中,将新旧值传入 cb 中。

function watch(source, cb, options?) {
  const { immediate = false, deep = false } = options;
  const getter = createWatchGetter(source, deep);

  let oldValue;
  let cleanup;

  // 初始化 effect,值变化时进行更新操作
  const _effect = new ReactiveEffect(getter, () => {
    const newValue = _effect.run(); // 获得最新的值

    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }

    cb(newValue, oldValue, (fn) => {
      cleanup = fn;
    });

    oldValue = newValue;
  });

  oldValue = _effect.run();

  // 立马执行一次 cb
  if (immediate) {
    cb(oldValue, undefined, (fn) => {
      cleanup = fn;
    });
  }

  return () => {
    if (cleanup) {
      cleanup();
      cleanup = undefined;
    }
    stopEffect(_effect);
  };
}

createWatchGetter:将 source 变为可执行的 getter,支持对 source 中的响应式属性进行依赖收集

source 支持的类型:ref,reactive、数组(进行遍历)、函数

function createWatchGetter(source: unknown, deep: boolean): () => unknown {
  if (isRef(source)) {
    return () => (source as { value: unknown }).value;
  }
  if (typeof source === "function") {
    return source as () => unknown;
  }
  if (isArray(source)) {
    return () =>
      (source as unknown[]).map((s) => {
        if (isRef(s)) {
          return (s as { value: unknown }).value;
        }
        if (typeof s === "function") {
          return (s as () => unknown)();
        }
        return s;
      });
  }
  if (isReactive(source)) {
    // deep 为 true 则深度监听,否则只监听一层
    const maxDepth = deep ? undefined : 1;
    return () => traverse(source, maxDepth);
  }
  return () => source;
}

清理函数:onCleanup 是回调的第三个参数,用来注册「下一次将要执行回调之前」或「停止监听时」会先执行的清理函数。

// 示例
watch(
  () => state.id,
  (id, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });

    fetch(`/api/user/${id}`).then((res) => {
      if (!cancelled) {
        state.user = res;
      }
    });
  },
);

停止监听:watch 的返回值可以返回 stopEffect

/**
 * 停止副作用:从各 dep 中移除并清空依赖列表,之后不再被 trigger。
 */
export function stopEffect(effect: ReactiveEffect) {
  if (!effect.active) {
    return;
  }
  effect.active = false; // 激活状态改为 false
  const deps = effect.deps;
  for (let i = 0; i < deps.length; i++) { // 并清理 effect 上的 deps
    cleanDepEffect(deps[i], effect);
  }
  effect.deps.length = 0;
}

选项api:flush

  • pre(默认):在同一轮事件里稍后跑(通常仍在微任务里),多在组件重新渲染之前调度,方便你在 DOM 还没更新时读旧 DOM、或先改别的状态。
  • post:DOM 更新之后再跑,适合依赖已更新后的 DOM(例如 ref 量尺寸)。
  • sync:一触发依赖更新,就同步、立刻执行回调,不排到微任务、也不等组件更新阶段。

用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个

用 Node.js 往复杂 Excel 模板里灌数据?现有库都差点意思,我手搓了一个

一个 Excel 模板里塞了透视表、图片、合并单元格、跨表公式——我只需要往数据页写几行数,为什么这么难?


先说场景

做企业报表的同学大概都遇到过这种模板:

  • 展示页:透视表、图表、嵌套合并单元格、图片、跨表公式,花里胡哨
  • 数据页:干干净净一个表格,被展示页的公式引用

需求很简单:Node.js 后端往数据页里写数据,展示页自动算出结果。

就这么个事。


试了一圈,都不行

exceljs

生态里最流行的 Excel 库,用的人最多。

问题在于它的工作方式是解析 → 内存对象 → 重建。也就是说,读进来的是它能理解的部分,读不进去的就丢了。

如果你的模板里有透视表、复杂图表、某些特定格式的图片——写出来再打开,大概率面目全非。

这不是 exceljs 的锅,它的设计目标本来就不是"保真"。

xlsx-populate

这个库比 exceljs 好一点,设计上就考虑了模板场景。但问题是:

  • 透视表?不支持
  • 复杂图表?不支持
  • 某些条件格式写完就丢

而且这个库更新频率不太稳定,有些 issue 挂很久。

SheetJS (xlsx)

性能好,能解析的东西多。但它本质上是个数据读取库,写入能力偏弱,尤其是样式和复杂对象的处理。

共同的问题

这些库都在做同一件事:把 xlsx 解析成内存对象,修改,再重新打包

问题就在"重新打包"这一步。xlsx 内部有几十个 XML 文件,互相之间有引用关系。解析的时候丢信息,打包的时候自然就出问题。


换个思路:别重建,做手术

先搞清楚 xlsx 到底是什么。把 .xlsx 后缀改成 .zip,解压:

xl/
├── workbook.xml          # 工作簿配置
├── _rels/
│   └── workbook.xml.rels # 工作表映射关系
├── worksheets/
│   ├── sheet1.xml        # 工作表数据(不一定叫 sheet1)
│   └── sheet7.xml        # 实际的工作表可能叫任何名字
├── styles.xml            # 所有样式定义
├── drawings/             # 图片资源
├── pivotTables/          # 透视表定义
├── calcChain.xml         # 公式计算链
└── sharedStrings.xml     # 共享字符串表

关键发现:数据页的内容只存在 worksheets/sheetN.xml<sheetData> 标签里

也就是说,理论上我只需要:

  1. 打开 zip
  2. 找到目标 worksheet
  3. 只改 <sheetData> 里的内容
  4. 其他文件一概不动
  5. 封包

样式、图片、透视表都不受影响——压根没碰它们。


设计原则

三条,很简单:

  1. 黑盒原则styles.xmldrawings/pivotTables/ 一律不碰
  2. 片段手术:只改目标 worksheet 的 <sheetData> 区域,其他 XML 片段原样保留
  3. 可诊断失败:遇到不支持的场景直接报错,不静默降级。报错带上错误码,好排查

核心实现

整个组件大概 600 行 TypeScript,只依赖 adm-zip(操作 zip)和 fast-xml-parser(局部辅助解析)。

1. worksheet 定位:不能假设 sheet1.xml

第一坑:worksheet 文件名不一定是 sheet1.xml

实际项目中,Excel 内部的文件可能是 sheet7.xmlsheet3.xml,跟你在 Excel 里看到的标签顺序不一定对应。直接猜文件名会出 bug。

正确做法是通过 workbook.xml + workbook.xml.rels 做映射:

// workbook.xml 里有每个 sheet 的 name 和 r:id
// <sheet name="Data" sheetId="1" r:id="rId1"/>

// workbook.xml.rels 里有 r:id 到实际文件的映射
// <Relationship Id="rId1" Target="worksheets/sheet7.xml"/>

export function resolveWorksheetPath(
  workbookXml: string,
  relsXml: string,
  sheetRef: SheetRef
): string {
  // 1. 从 workbook.xml 找到目标 sheet 的 r:id
  // 2. 从 rels 找到 r:id 对应的 Target
  // 3. 拿到真实路径,比如 "xl/worksheets/sheet7.xml"
}

这样不管 Excel 内部怎么编号,都能精准定位。

2. 数据注入:直接拼 XML

数据注入的本质是生成 <row><c>(cell)节点,替换掉原来的 <sheetData> 内容。

不同类型的数据,生成的 XML 不一样:

function buildCellXml(cellRef: string, value: unknown, ...): string {
  // 数字
  if (typeof value === 'number') {
    return '<c r="' + cellRef + '" t="n"><v>' + value + '</v></c>';
  }

  // 字符串:用 inlineStr,不走共享字符串表
  // 为什么不用 sharedStrings?因为改那个索引太容易出错了
  return '<c r="' + cellRef + '" t="inlineStr"><is><t>' + escapeXmlText(String(value)) + '</t></is></c>';

  // 日期:转成序列号,当作数字写入
  // 布尔:t="b",值写 0/1
}

注意字符串用的是 inlineStr 而不是共享字符串表(sharedStrings.xml)。原因是改共享字符串表的索引很容易搞乱其他单元格,inlineStr 虽然文件稍大一点,但安全。

3. 行扩展策略

写入数据时,数据行数可能比模板里的行多,也可能少。两种策略:

  • 模式 A(覆盖):只往已有行里写数据,多出来的行不要。适合固定行数的模板。
  • 模式 B(扩展):允许新增行,新行会继承附近行的样式索引。

样式继承的逻辑:

// 新增行时,向上扫描同列,找到最近的带样式的单元格
private resolveInheritedStyle(
  existingRowsMap: Map<number, string>,
  rowIndex: number,
  col: number
): string | undefined {
  let cursor = rowIndex - 1;
  while (cursor > 0) {
    const xml = existingRowsMap.get(cursor);
    if (!xml) { cursor -= 1; continue; }
    // 先找同列的样式
    // 找不到就找这一行任意一个有样式的单元格
    // 还找不到就继续往上一行找
  }
  return undefined;
}

这样新增的行不会变成"裸奔"状态,至少能继承模板的基本样式。

4. 冲突检测:行扩展前先扫雷

模式 B 扩展行的时候,新行可能覆盖到一些不能碰的东西:

  • 合并单元格(mergeCells)
  • 数据校验规则(dataValidations)
  • 条件格式(conditionalFormatting)
  • 表格对象(tableParts)
  • 命名区域(definedNames)

所以扩展之前先做一次矩形碰撞检测:

export function detectRangeConflicts(
  worksheetXml: string,
  targetRange: RangeRect,
  strictMode: boolean
): string[] {
  // 从 XML 中提取 mergeCells、dataValidations、conditionalFormatting 的范围
  // 跟目标写入范围做矩形相交判断
  // 严格模式下直接抛 E_UNSUPPORTED_RANGE 错误
  // 宽松模式下收集告警,继续执行
}

严格模式下,有冲突直接报错终止。

5. 日期体系的坑

Excel 有两套日期体系:1900 和 1904。macOS 版 Excel 默认用 1904,Windows 版用 1900。

同一个日期,两套体系算出来的序列号差 1462 天。如果不管这个,写入的日期就会偏移四年多。

更离谱的是,1900 体系里有个著名 bug:Excel 认为 1900 年是闰年,2 月 29 日是"存在的"(实际上 1900 不是闰年)。所以序列号 60 对应的是这个不存在的日期,60 以后的序列号都要 +1。

export function toExcelDate(date: Date, date1904: boolean): number {
  if (date1904) {
    // 1904 体系:从 1904-01-01 开始算
    return Math.floor((utc.getTime() - base1904.getTime()) / DAY_MS);
  }

  // 1900 体系:从 1899-12-31 开始算
  let serial = Math.floor((utc.getTime() - base1900.getTime()) / DAY_MS);
  // 兼容 Excel 的 1900 闰年 bug
  if (serial >= 60) {
    serial += 1;
  }
  return serial;
}

组件会自动检测模板用的是哪套体系,按模板的体系转换。

6. 公式重算

数据写进去了,展示页的公式要重新算。但 Node.js 里没有 Excel 计算引擎,怎么办?

答案是:让 Excel 自己算

private applyRecalcPolicy(mode: RecalcMode): void {
  // 删掉 calcChain.xml(旧的计算缓存)
  this.zip.deleteFile('xl/calcChain.xml');

  // 在 workbook.xml 里设置全量重算标记
  // Excel/WPS 打开文件时会自动重算所有公式
  workbookObj.workbook.calcPr['@_fullCalcOnLoad'] = '1';
  workbookObj.workbook.calcPr['@_forceFullCalc'] = '1';
}

这样用户打开文件的时候,Excel 会自动把所有公式重算一遍。代价是第一次打开会慢几秒(取决于公式数量),但结果一定是正确的。


完整用法

import { ExcelSurgicalLink } from './src';

// 从本地模板创建
const link = new ExcelSurgicalLink('template.xlsx');

// 注入数据
link.inject(
  [
    ['商品A', 100, new Date('2026-04-01')],
    ['商品B', 120, new Date('2026-04-02')]
  ],
  {
    sheetRef: { name: 'Data' },      // 按名称定位工作表
    rowExpansion: 'B',                // 允许行扩展
    dateHandling: 'serial',           // 日期写序列号
    recalcMode: 'full',               // 全量重算
    strictMode: 'strict',             // 严格模式
    onUnsupportedFeature: 'error',    // 不支持的特性直接报错
    startCell: 'A2'                   // 从 A2 开始写
  }
);

// 保存
link.save('output.xlsx');

也支持远程模板——从 URL 拉模板,写完直接上传:

const link = await ExcelSurgicalLink.fromSource(
  'https://your-server.com/template.xlsx',
  { headers: { Authorization: 'Bearer token' }, timeoutMs: 10000 }
);

// ... 注入数据 ...

await link.saveToRemote(
  'https://your-server.com/output.xlsx',
  { method: 'PUT', headers: { Authorization: 'Bearer token' } }
);

效果

核心指标:

  • 样式保全styles.xmldrawings/pivotTables/ 字节级不变
  • 公式正确:展示页公式打开后自动重算,结果与输入数据一致
  • 可打开性:Excel(Windows/Mac)和 WPS 打开无修复提示
  • 依赖极简:只依赖 adm-zip + fast-xml-parser

已知边界

实事求是,没做完的就是没做完:

功能 状态 说明
固定区域写入 完全支持
样式/图片/透视表保全 字节级不变
日期体系兼容 1900/1904 自动识别
公式重算触发 fullCalcOnLoad
行扩展 + 样式继承 模式 B
冲突检测 五类对象
远程模板读写 HTTP(S)
结构化表(ListObject)自动扩展 还没做
definedNames 动态重写 当前为保护性拦截
大规模性能压测 SLO 报告待补

最后

这个组件的思路其实不复杂:别重建,只做手术

xlsx 是个 zip 包,数据就在几个 XML 标签里。与其让库帮你解析→重建(顺便丢信息),不如直接上手改那几行 XML。

当然,这个方案也有适用范围——它适合"模板复杂、数据写入点固定"的场景。如果你需要动态创建图表、动态生成透视表,那还是得用更重的方案。

代码在本地跑着,等什么时候有空了整理一下放 GitHub。

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

【Swift Concurrency】彻底告别回调地狱——async/await、Task、Actor 系统精讲

iOS 进阶必修 · Swift 并发编程系列 第 1 期


一、一句话介绍

Swift Concurrency 是 Apple 在 Swift 5.5(iOS 15+)正式引入的原生并发框架,它让异步代码的编写、错误处理、线程安全变得声明式、结构化、且编译器可静态验证。

属性 信息
引入版本 Swift 5.5 / Xcode 13
运行时最低要求 iOS 13+(back-deploy)/ iOS 15+ 全功能
核心特性 async/await · Task · Actor · AsyncStream
与 Combine 关系 互补共存,AsyncSequence 可与 Combine 互转
官方文档 Swift Concurrency

二、为什么选择它

原生异步方案的痛点

在 Swift Concurrency 出现之前,iOS 异步编程长期面临这些问题:

旧方案 Swift Concurrency
回调嵌套(Callback Hell),可读性极差 async/await 线性写法,与同步代码几乎一致
DispatchQueue + 锁保护共享状态,极易出错 actor 编译器静态保证线程安全
DispatchGroup 聚合多个并行任务,样板代码多 async let / withTaskGroup 声明式并行
任务取消需要自行维护 flag,容易遗漏 结构化取消,父取消子自动跟随
线程切换 DispatchQueue.main.async {} 到处散落 @MainActor 注解,编译器强制保证主线程
Combine 学习曲线陡,操作符多 AsyncStream 原生支持,与 for await 天然融合

核心优势:

  • 可读性:async/await 让异步代码读起来像同步,减少 80% 认知负担
  • 安全性:actor 让数据竞争成为编译错误而非运行时崩溃
  • 结构化:父子任务形成树形结构,取消/错误自动传播
  • 可组合:AsyncSequence 统一了事件流、定时器、网络流的消费模型
  • 零依赖:语言内置,无需引入任何第三方库

三、核心功能速览

基础层(新手必读)

无需配置,开箱即用

Swift Concurrency 是语言特性,直接在 Xcode 13+ 的任意 Swift 文件中使用:

// Swift 5.5+ · iOS 13+ (back-deploy) / iOS 15+ (全功能)
import Foundation  // 仅需标准库

async/await:异步函数的声明与调用

// ✅ 声明异步函数:加 async 关键字
func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

// ✅ 调用:必须在 async 上下文中,用 await 挂起
Task {
    do {
        let user = try await fetchUser(id: 1)
        print(user.name)
    } catch {
        print("加载失败:\(error)")
    }
}

await挂起点而非阻塞点:挂起时线程被释放,恢复后可能在不同线程继续执行。这是 Swift Concurrency 高效的根本原因。


SwiftUI 中使用 .task 修饰符(推荐)

struct UserView: View {
    @State private var user: User?

    var body: some View {
        Text(user?.name ?? "加载中...")
            .task {
                // 视图消失时任务自动取消,无需手动管理
                user = try? await fetchUser(id: 1)
            }
    }
}

进阶层(最佳实践)

async let:并行执行多个任务

// ❌ 顺序执行:总耗时 = 500ms + 300ms + 200ms = 1000ms
let user    = try await fetchUser(id: 1)
let orders  = try await fetchOrders(uid: 1)
let profile = try await fetchProfile(uid: 1)

// ✅ async let 并行:总耗时 = max(500ms, 300ms, 200ms) = 500ms
async let user    = fetchUser(id: 1)
async let orders  = fetchOrders(uid: 1)
async let profile = fetchProfile(uid: 1)
let (u, o, p) = try await (user, orders, profile)
// 三行代码实现并行,耗时减半

withTaskGroup:动态数量的并行任务

// 并行下载数量不固定的图片列表
func downloadImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask { try await fetchImage(from: url) }
        }
        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}

Task:非结构化任务与取消

// 创建任务(继承当前 actor 上下文)
let task = Task(priority: .userInitiated) {
    for i in 1...100 {
        try Task.checkCancellation()   // 取消时自动 throw CancellationError
        await processItem(i)
    }
}

// 取消(协作式,不会强制停止)
task.cancel()

// Task.detached:不继承 actor 上下文,完全独立
Task.detached(priority: .background) {
    let result = await heavyComputation()
    await MainActor.run { updateUI(result) }
}

Continuation:桥接旧式回调 API

// 将旧式 completion block API 包装为 async 函数
func requestLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locationManager.requestLocation { location, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let location {
                continuation.resume(returning: location)
            }
        }
    }
}
// ⚠️ resume 只能调用一次,多次调用会 crash

深入层(源码视角)

核心模块职责划分

特性 职责 适用场景
async/await 异步函数声明与挂起 任何异步 IO 操作
async let 静态数量并行任务 首页多接口聚合
Task 非结构化任务单元 按钮触发的独立操作
withTaskGroup 动态数量结构化并发 批量下载/处理
actor 数据竞争保护 共享状态管理
@MainActor 主线程强制约束 UI 更新
Sendable 跨边界类型安全 actor 参数/返回值
AsyncStream 自定义异步序列 事件流/实时数据

四、实战演示

场景:AI 流式问答 + 打字机渲染

这是目前最热门的应用场景之一,完整演示了 AsyncStream + Task + @MainActor 的协同工作。

// Swift 5.5+

// MARK: - 1. 流式 AI 服务层(可替换为真实 SSE 接口)

enum AIStreamService {
    /// Mock:逐字符推送,实际项目替换为 URLSession.bytes 读取 SSE
    static func stream(prompt: String) -> AsyncStream<String> {
        let response = "Swift Concurrency 让并发编程如行云流水," +
            "async/await 消除回调地狱,Actor 守护数据安全," +
            "AsyncStream 带来流式体验。🚀"

        return AsyncStream { continuation in
            Task {
                for char in response {
                    guard !Task.isCancelled else {
                        continuation.finish()
                        return
                    }
                    continuation.yield(String(char))
                    try? await Task.sleep(nanoseconds: 60_000_000) // 60ms/字
                }
                continuation.finish()
            }
        }
    }

    /// 接入真实 SSE 接口(生产参考)
    static func streamFromSSE(url: URL) -> AsyncStream<String> {
        AsyncStream { continuation in
            Task {
                let (bytes, _) = try await URLSession.shared.bytes(from: url)
                for try await line in bytes.lines {
                    guard line.hasPrefix("data: "),
                          let data = line.dropFirst(6).data(using: .utf8),
                          let json = try? JSONDecoder().decode(TokenResponse.self, from: data)
                    else { continue }
                    continuation.yield(json.token)
                }
                continuation.finish()
            }
        }
    }
}

// MARK: - 2. SwiftUI 打字机视图

struct TypewriterView: View {
    @State private var prompt = "Swift 并发编程"
    @State private var output = ""
    @State private var isStreaming = false
    @State private var streamTask: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("输入问题…", text: $prompt)
                .textFieldStyle(.roundedBorder)

            // 打字机光标效果
            Text(output + (isStreaming ? "▌" : ""))
                .font(.body)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
                .background(Color(.secondarySystemBackground))
                .cornerRadius(10)
                .animation(.none, value: output)

            HStack(spacing: 12) {
                Button(isStreaming ? "生成中…" : "开始生成") {
                    startStream()
                }
                .buttonStyle(.borderedProminent)
                .disabled(isStreaming)

                Button("停止") {
                    streamTask?.cancel()
                    isStreaming = false
                }
                .buttonStyle(.bordered)
                .tint(.red)
                .disabled(!isStreaming)
            }
        }
        .padding()
        .onDisappear { streamTask?.cancel() } // ✅ 离开页面时取消
    }

    private func startStream() {
        streamTask?.cancel()
        output = ""
        isStreaming = true
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: prompt) {
                output += token  // SwiftUI 自动感知变化实时渲染
            }
            isStreaming = false
        }
    }
}

// MARK: - 3. UIKit 打字机控制器(@MainActor 保证 UI 安全)

@MainActor
class TypewriterViewController: UIViewController {
    private let textView = UITextView()
    private var streamTask: Task<Void, Never>?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        streamTask?.cancel()  // ✅ 离开页面时取消,防止内存泄漏
    }

    @objc func startStream() {
        streamTask?.cancel()
        textView.text = ""
        streamTask = Task {
            for await token in AIStreamService.stream(prompt: "UIKit") {
                guard !Task.isCancelled else { break }
                textView.text += token
                // 自动滚到底部
                let range = NSRange(location: textView.text.count - 1, length: 1)
                textView.scrollRangeToVisible(range)
            }
        }
    }
}

这个示例完整演示了:AsyncStream 的创建与消费、Task 的取消管理、@MainActor 的 UI 安全保证、SwiftUI 和 UIKit 的两套接入方式。


五、源码亮点

进阶层:值得借鉴的设计

Actor 并发计数器(告别 DispatchQueue + 锁)

// ❌ 传统写法:容易因忘记加锁而出现数据竞争
class Counter {
    var value = 0
    let queue = DispatchQueue(label: "counter.queue")
    func increment() { queue.sync { value += 1 } }
}

// ✅ actor:编译器静态保证,忘加 await 直接报错
actor SafeCounter {
    private(set) var value = 0
    func increment() { value += 1 }
}

// 并发使用:1000 个任务同时递增,结果一定是 1000
let counter = SafeCounter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1000 {
        group.addTask { await counter.increment() }
    }
}
print(await counter.value)  // 1000,绝无数据竞争

AsyncStream 资源安全回收

// 定时器流:onTermination 防止 timer 泄漏
func timerStream(interval: Double) -> AsyncStream<Int> {
    AsyncStream { continuation in
        var tick = 0
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
            tick += 1
            continuation.yield(tick)
        }
        // ✅ 流取消/结束时自动调用,清理外部资源
        continuation.onTermination = { _ in
            timer.invalidate()
        }
    }
}

深入层:设计思想解析

结构化并发:任务树模型

Swift Concurrency 引入了"结构化并发"概念——任务形成父子树形结构:

父任务(Task)
 ├── 子任务 A(async let)
 ├── 子任务 B(async let)
 └── TaskGroup
      ├── 子任务 C(addTask)
      └── 子任务 D(addTask)

关键特性:

  • 父取消 → 子自动取消:无需手动遍历
  • 子抛出错误 → 父捕获:错误自动冒泡
  • 父作用域结束 → 等待所有子完成:无任务泄漏

这与 Kotlin 协程的 StructuredConcurrency 思想一脉相承,但 Swift 通过编译器强制实施,更难写错。


Actor 的可重入设计

Actor 内部通过隐式串行队列保证数据安全,但它是可重入的:

actor BankAccount {
    var balance: Double = 1000

    // ⚠️ 重入陷阱:await 挂起期间,其他任务可进入 actor 修改 balance
    func withdrawUnsafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        await logTransaction(amount)  // 挂起!balance 可能被别的 withdraw 修改
        balance -= amount             // 此时 balance 可能已不足!
    }

    // ✅ 正确:先修改状态再 await
    func withdrawSafe(amount: Double) async throws {
        guard balance >= amount else { throw BankError.insufficient }
        balance -= amount          // 先扣,在 await 之前完成关键状态变更
        await logTransaction(amount)
    }
}

规则:actor 中,await 之前必须完成所有关键状态变更。


六、踩坑记录

问题 1:Continuation.resume 调用了多次导致 crash

  • 原因:某些旧 SDK 的 completion block 可能被调用多次(如进度回调)
  • 解决:用 bool flag 保护,确保 resume 只执行一次
func safeContinuation<T>(_ block: (@escaping (T) -> Void) -> Void) async -> T {
    await withCheckedContinuation { continuation in
        var resumed = false
        block { value in
            guard !resumed else { return }
            resumed = true
            continuation.resume(returning: value)
        }
    }
}

问题 2:Task.detached 中直接更新 UI 导致崩溃

  • 原因Task.detached 不继承当前 actor 上下文,不在主线程
  • 解决:显式切回主线程
// ❌ 危险
Task.detached { self.label.text = "done" }

// ✅ 正确
Task.detached {
    let result = await process()
    await MainActor.run { self.label.text = result }
}

问题 3:视图消失后 Task 仍在运行,导致内存泄漏

  • 原因:Task 生命周期独立于视图,视图销毁后任务仍持有 self
  • 解决:SwiftUI 用 .task {} 修饰符(自动管理),UIKit 在 viewWillDisappear 中 cancel
// UIKit
override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    loadTask?.cancel()
}

问题 4:Actor 重入性导致余额多扣

  • 原因:await 挂起期间其他任务进入 actor 修改共享状态
  • 解决:遵守"先修改状态,再 await"原则(见第五章深入层)

问题 5:AsyncStream 中 timer / 监听器未释放,持续运行

  • 原因:忘记实现 continuation.onTermination
  • 解决:每个 AsyncStream 必须实现 onTermination,清理外部资源
continuation.onTermination = { reason in
    timer.invalidate()
    notificationCenter.removeObserver(observer)
}

问题 6:withTaskGroup 中子任务抛出错误没有被感知

  • 原因:使用了 withTaskGroup(不抛出版),错误被吞掉
  • 解决:需要错误传播时,使用 withThrowingTaskGroup
// ✅ 任意子任务失败,整个 group 取消并抛出错误
try await withThrowingTaskGroup(of: Data.self) { group in
    for url in urls { group.addTask { try await fetch(url) } }
    for try await data in group { process(data) }
}

问题 7:在 iOS 13 / 14 上使用 actor 报链接错误

  • 原因:actor 运行时需要 iOS 15+ 的系统库支持;Xcode back-deploy 支持 async/await 但不完全支持 actor
  • 解决:确认最低 Deployment Target,或对 actor 用 @available(iOS 15, *) 包裹

七、延伸思考

与同类方案横向对比

方案 简介 学习曲线 线程安全 取消支持 适用场景
Swift Concurrency Swift 原生,语言级别支持 编译器保证(actor) 结构化取消 新项目首选
GCD + DispatchQueue 苹果传统并发方案 手动加锁,容易出错 无原生支持 老项目维护
Combine 响应式框架,操作符丰富 需手动 receive(on:) AnyCancellable 复杂数据流转换
PromiseKit 基于 Promise 的链式回调 无特殊支持 有限支持 OC/早期 Swift 项目
RxSwift 响应式编程全家桶 很高 需配置 scheduler Disposable 重度响应式架构

推荐使用场景

  • ✅ iOS 13+ 新项目,全面拥抱 Swift Concurrency
  • ✅ 需要并行聚合多个接口的页面(async let / TaskGroup)
  • ✅ 共享状态管理,替代 DispatchQueue + 锁(actor)
  • ✅ 实时数据流、WebSocket、AI 流式响应(AsyncStream)
  • ✅ 需要优雅取消的长时任务(下载、文件处理)

不推荐场景

  • ❌ 项目最低支持 iOS 12 及以下,部分特性无法使用
  • ❌ 已有大量 Combine 代码,短期内迁移成本过高
  • ❌ 需要复杂响应式操作符链(merge、combineLatest 等),Combine 更合适

迁移策略建议

  1. 新功能优先用 async/await,不强制改旧代码
  2. 旧接口Continuation 包装,对调用方透明
  3. Combine Pipeline 可通过 .values 属性转为 AsyncSequence 互通
  4. Swift 6 开启严格并发检查(-strict-concurrency=complete),提前消灭隐患

八、参考资源


九、本期互动

小作业

基于本文的 AsyncStream 示例,实现一个实时心跳检测器

  1. AsyncStream 每隔 1 秒 yield 一次当前时间戳
  2. 连续 5 次 yield 后,主动调用 continuation.finish() 结束流
  3. 在 SwiftUI 中用 .task {} 消费流,将每次时间戳展示在列表中
  4. 点击「停止」按钮时,通过 task.cancel() 终止流,并验证 onTermination 被调用

完成后在评论区贴出你的 AsyncStream 创建代码和 onTermination 实现,说明你是如何验证资源正确释放的。


思考题

Swift Concurrency 的 actor 选择了可重入设计——即 await 挂起时允许其他任务进入 actor 执行。你认为这个设计决策是合理的吗?

如果设计成不可重入(像传统锁一样),会带来哪些问题?可重入又会引发哪些坑?在你的实际项目中,你更希望哪种行为?


读者征集

下一期预计深入讲解 Swift Concurrency 进阶:自定义 AsyncSequence、结构化并发原理、Swift 6 严格并发检查实战

如果你在项目中迁移到 Swift Concurrency 时踩过坑(特别是 actor 重入、Sendable 编译报错、Task 泄漏等),欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列持续更新 ➡️ 第 1 期:Swift Concurrency 基础精讲(本期)· ○ 第 2 期:Swift Concurrency 进阶 · ○ 第 3 期:待定 · ○ 第 4 期:待定

前端架构实操:地铁出行系统高并发与性能优化全解析(二)

一、引言:从行业通用场景出发,理清高并发与性能优化的核心逻辑

作为前端备考软考架构师的伙伴,我们都清楚,中大型项目的核心挑战,从来不是 “实现功能”,而是 “扛住流量、保证体验”。

本篇继续围绕我了解到的地铁出行系统(中大型微服务项目),聚焦高并发与性能优化核心场景,完整拆解「项目场景→实际问题→思考过程→技术选型→解决方案→实施结果」,既梳理性能优化类技术体系,又还原项目实操逻辑,帮大家吃透技术落地思路,同时规避保密风险。


二、项目场景:地铁出行系统高并发与性能现状

结合行业通用地铁出行系统项目特点,该类项目的高并发与性能相关场景如下,为后续问题排查和技术选型奠定基础:

  1. 用户规模与流量特点:服务全市 500 万 + 用户,早晚高峰(7:00-9:00、17:00-19:00)为流量峰值,瞬时并发量可达平日的 5-8 倍,核心页面(实时到站、客流监控)需承载高并发请求;

  2. 架构现状:后端 6 个微服务(线路管理、实时到站、客流监控、用户管理、票务支付、站点设施管理)独立部署,前端采用 Vue3+Pinia 技术栈,已通过 BFF 层 + Nacos 解决接口与环境问题,但随着用户量增长,性能瓶颈逐步凸显;

  3. 部署环境:4 套环境(开发、测试、仿真、生产),后端服务需根据早晚高峰客流动态扩容 / 缩容,对系统弹性扩缩容能力要求极高。


三、项目痛点:高并发与性能优化中遇到的实际问题

在项目迭代过程中,随着用户量持续增长,早晚高峰时段系统出现了多个核心性能问题,严重影响用户体验和系统稳定性,具体如下:

1. 静态资源加载慢,页面首屏渲染超时

地铁出行系统包含大量静态资源(线路地图、站点图片、样式文件、JS 代码包),初期采用 “前端直连服务器” 的方式加载资源,遇到 3 个核心问题:

  • 资源分发效率低:静态资源存储在后端服务器,用户跨区域访问时,网络延迟高,首屏加载时间长达 8-10 秒,远超用户可接受的 3 秒阈值

  • 服务器带宽压力大:早晚高峰时,大量用户同时请求静态资源,后端服务器带宽被占满,导致接口请求延迟、页面加载失败;

  • 缓存策略不合理:未做合理的资源缓存配置,用户每次访问都重新加载全量资源,进一步加剧服务器压力。

2. 高并发场景下,服务扩容不及时,系统崩溃风险高

早晚高峰瞬时并发量激增,后端微服务(尤其是实时到站、票务支付服务)负载过高,出现以下问题:

  • 扩容响应慢:传统手动扩容方式,需运维人员手动部署服务器、配置服务,耗时长达 1-2 小时,无法应对突发流量高峰;

  • 服务稳定性差:服务过载时,出现接口超时、请求失败,甚至服务宕机,导致用户无法查询实时到站、无法购票,严重影响出行体验;

  • 资源浪费严重:平峰时段服务器负载低,手动缩容不及时,造成大量服务器资源闲置,运维成本陡增。

3. 大数据量页面渲染卡顿,用户交互体验差

客流监控、线路查询等页面,需展示大量实时数据(如全线路客流数据、历史到站记录),前端直接渲染全量数据,出现:

  • 页面渲染卡顿:大数据量渲染导致主线程阻塞,页面滚动、点击等交互操作延迟,甚至出现页面卡死;

  • 内存占用过高:全量数据加载导致浏览器内存占用飙升,部分低端设备出现闪退;

  • 数据更新不及时:实时数据频繁更新,未做合理的渲染优化,导致页面频繁重绘,进一步加剧卡顿。


四、思考过程:从问题出发,拆解破局思路

面对上述 3 个核心问题,相关开发团队没有盲目选型技术,而是从「提升用户体验、降低运维成本、增强系统稳定性」三个核心目标出发,逐步拆解思考,形成了清晰的破局思路:

针对 “静态资源加载慢” 问题的思考

核心需求:提升静态资源加载速度,降低服务器带宽压力,实现用户就近访问,优化首屏渲染体验。思考拆解

  1. 痛点本质:静态资源集中存储在后端服务器,用户跨区域访问延迟高,且未做缓存优化,导致服务器带宽压力大、首屏加载慢;

  2. 核心思路:引入内容分发网络(CDN) ,将静态资源缓存到全国各区域节点,用户就近访问节点资源,大幅降低网络延迟;同时优化资源缓存策略,减少重复请求;

  3. 技术选型考量:对比自建 CDN 与第三方商用 CDN—— 自建 CDN 部署成本高、维护难度大,不适合中大型项目;第三方商用 CDN(如阿里云 CDN、腾讯云 CDN)部署简单、节点覆盖广,能快速解决资源加载问题,因此确定选用 CDN 作为静态资源优化方案。

针对 “高并发服务扩容难” 问题的思考

核心需求:实现服务自动扩缩容,应对突发流量高峰,提升系统稳定性,同时降低运维成本,避免资源浪费。思考拆解

  1. 痛点本质:传统手动扩容 / 缩容方式,响应速度慢、效率低,无法适配地铁项目 “早晚高峰流量波动大” 的特点,且运维成本高;

  2. 核心思路:引入容器化编排工具,将后端微服务、BFF 层打包成容器,通过编排工具实现服务的自动部署、弹性扩缩容、故障自愈;

  3. 技术选型考量:对比 Docker+K8s(Kubernetes)与其他容器化方案 ——Docker 实现容器化打包,保证环境一致性;K8s 实现容器编排,支持自动扩缩容、服务治理,是行业内微服务容器化的标准方案,因此确定选用「Docker+K8s」作为容器化编排方案。

针对 “大数据量渲染卡顿” 问题的思考

核心需求:优化大数据量页面渲染性能,避免主线程阻塞,提升用户交互体验,降低浏览器内存占用。思考拆解

  1. 痛点本质:前端一次性加载并渲染全量数据,导致主线程阻塞、内存占用过高,页面交互卡顿;

  2. 核心思路:采用虚拟列表 + 懒加载技术,仅渲染可视区域内的数据,按需加载剩余数据,减少 DOM 节点数量,降低主线程压力;同时优化数据更新逻辑,避免频繁重绘;

  3. 技术选型考量:虚拟列表(如 vue-virtual-scroller)是前端大数据量渲染的通用优化方案,适配 Vue3 技术栈,无需额外引入复杂框架,开发成本低、优化效果显著,因此确定选用虚拟列表 + 懒加载作为渲染优化方案。

整体思考总结

最终形成「CDN+Docker+K8s + 虚拟列表」的技术栈组合,各技术针对性解决对应痛点:CDN 解决静态资源加载慢,Docker+K8s 解决高并发扩容难,虚拟列表解决大数据量渲染卡顿,形成完整的高并发与性能优化解决方案,贴合地铁出行系统的业务特点,同时符合软考架构师 “技术选型贴合项目需求” 的核心要求。


五、解决方案:CDN+Docker+K8s + 虚拟列表技术栈落地细节

结合上述行业通用思考思路,相关开发团队落地了完整的高并发与性能优化方案,每一项技术都严格贴合项目需求,具体落地细节如下:

1. CDN 落地:静态资源加速与缓存优化

  • 核心功能落地

    1. 资源分发:将地铁出行系统的所有静态资源(线路地图、站点图片、JS/CSS 代码包、字体文件)上传至 CDN,缓存到全国各区域节点,用户访问时,自动路由到最近的节点获取资源;

    2. 缓存策略优化:针对不同类型资源设置差异化缓存时间 —— 静态资源(图片、样式)设置 7 天缓存,JS 代码包设置 1 天缓存,同时配置版本号,避免缓存过期导致的资源更新不及时;

    3. 回源策略优化:设置 CDN 回源规则,仅在缓存过期时回源到后端服务器获取最新资源,减少服务器带宽压力;

  • 大白话理解:CDN 就像是 “全国连锁的资源便利店”,把静态资源提前放到用户家门口的便利店,用户不用再跑到后端服务器(总店)取资源,就近就能拿到,速度大幅提升,还能减轻总店的压力。

2. Docker+K8s 落地:容器化编排与自动扩缩容

  • 核心功能落地

    1. 容器化打包:将后端 6 个微服务、BFF 层分别打包成 Docker 镜像,保证开发、测试、仿真、生产环境的一致性,避免 “本地运行正常,线上报错” 的问题;

    2. K8s 集群部署:搭建 K8s 集群,部署所有容器化服务,配置服务发现、负载均衡,实现服务的自动部署与故障自愈;

    3. 自动扩缩容配置:基于 CPU 使用率、请求量设置 HPA(Horizontal Pod Autoscaler),早晚高峰流量激增时,自动扩容服务实例;平峰时段自动缩容,节省服务器资源;

    4. 运维自动化:通过 K8s 实现服务的一键部署、滚动更新,无需手动操作服务器,大幅降低运维成本;

  • 大白话理解:Docker 就像是 “集装箱”,把服务和运行环境打包成统一的集装箱,不管在哪都能正常运行;K8s 就像是 “智能调度中心”,自动管理这些集装箱,根据流量多少,自动增减集装箱数量,应对高峰、节省资源。

3. 虚拟列表 + 懒加载落地:大数据量渲染优化

  • 核心功能落地

    1. 虚拟列表实现:针对客流监控、线路查询等大数据量页面,引入 vue-virtual-scroller 组件,仅渲染可视区域内的 10-20 条数据,滚动时动态加载剩余数据,大幅减少 DOM 节点数量;

    2. 懒加载优化:图片、非首屏数据采用懒加载,仅当用户滚动到可视区域时,再加载对应资源,减少首屏加载时间;

    3. 渲染优化:优化数据更新逻辑,采用虚拟滚动 + 防抖处理,避免频繁重绘,保证页面交互流畅;

  • 大白话理解:虚拟列表就像是 “无限长的名单,但只给你看当前屏幕上的几行”,滚动时再替换内容,不用一次性渲染全部名单,页面自然不卡顿。

4. 技术协同落地:全链路优化逻辑

各技术并非独立使用,而是形成协同闭环,确保优化效果最大化:

  1. CDN 加速静态资源,减少首屏加载时间,降低服务器带宽压力,为 K8s 服务预留更多资源处理业务请求;

  2. Docker+K8s 实现服务自动扩缩容,应对 CDN 加速后带来的更高并发请求,保证系统稳定性;

  3. 虚拟列表优化前端渲染,配合 CDN 资源加速,共同提升用户体验,形成 “前端渲染 + 资源加速 + 后端扩容” 的全链路优化。


六、实施结果:问题解决成效与技术体系总结

方案落地后,相关开发团队对系统性能、用户体验、运维成本进行了统计,核心成效显著,同时梳理性能优化类技术体系,夯实备考基础:

1. 实施成效(量化呈现,贴合项目实际)

  • 静态资源加载优化:首屏加载时间从 8-10 秒缩短至 2-3 秒,用户访问成功率从 85% 提升至 99.5%,服务器带宽压力降低 70%;

  • 高并发扩容优化:服务扩容响应时间从 1-2 小时缩短至 5 分钟内,早晚高峰服务宕机率从 15% 降至 0,服务器资源利用率提升 60%,运维成本降低 50%;

  • 大数据量渲染优化:页面渲染卡顿率从 20% 降至 1% 以下,浏览器内存占用降低 40%,用户交互体验大幅提升;

  • 系统稳定性提升:全链路优化后,系统整体可用性从 99.2% 提升至 99.99%,完全满足地铁出行系统的高并发、高可用需求。

2. 性能优化技术体系梳理(融入技术体系化思路)

通过本次对行业通用地铁出行系统项目的梳理,总结出高并发与性能优化相关的技术体系,方便后续备考记忆和项目复用:

  1. 技术分类:本次落地的 CDN、Docker+K8s、虚拟列表,分属不同优化维度,覆盖全链路性能提升:

    • CDN:属于「静态资源加速技术」,核心解决资源加载慢、带宽压力大的问题,适配中大型项目的静态资源分发;

    • Docker+K8s:属于「容器化编排技术」,核心解决服务扩缩容、系统稳定性问题,适配微服务架构的高并发场景;

    • 虚拟列表 + 懒加载:属于「前端渲染优化技术」,核心解决大数据量渲染卡顿问题,适配前端大数据量页面场景;

  2. 技术选型逻辑:技术选型的核心是 “针对性解决痛点”,CDN 解决资源问题,Docker+K8s 解决扩容问题,虚拟列表解决渲染问题,三者协同形成全链路优化,这也是架构设计的核心思路;

  3. 备考记忆技巧:可总结为 “资源慢用 CDN,扩容难用 K8s,渲染卡用虚拟列表”,后续学习其他性能优化技术时,也按「场景→问题→思考→选型→落地」的思路梳理,贴合项目实操与软考备考。


七、自我复盘 + 下一篇预告

自我复盘:本次围绕地铁出行系统的高并发与性能优化,完整拆解了从问题到解决方案的全流程,让我深刻体会到,性能优化不是 “堆砌技术”,而是 “针对痛点精准选型”。同时,通过梳理技术体系,夯实了性能优化类技术的基础,也为软考论文中 “高并发与性能优化” 模块的写作,积累了完整的项目场景与落地逻辑。

下一篇,我们继续围绕地铁出行系统,聚焦工程化与监控场景 —— 随着项目迭代,代码质量、构建效率、系统监控成为新的痛点,我们将拆解「工程化场景→核心问题→思考过程→Webpack+GitLab CI+Prometheus 等技术栈落地→实施成效」,既梳理工程化技术体系,又还原项目实操逻辑,帮大家吃透工程化建设的核心思路。

最后,非常非常欢迎大家在评论区分享自己的想法、补充相关实操经验,也欢迎大家指出文中不对的地方,一起交流、一起进步,共同吃透前端架构实操与软考备考要点。

每日一题-机器人能否返回原点🟢

在二维平面上,有一个机器人从原点 (0, 0) 开始。给出它的移动顺序,判断这个机器人在完成移动后是否在 (0, 0) 处结束

移动顺序由字符串 moves 表示。字符 move[i] 表示其第 i 次移动。机器人的有效动作有 R(右),L(左),U(上)和 D(下)。

如果机器人在完成所有动作后返回原点,则返回 true。否则,返回 false

注意:机器人“面朝”的方向无关紧要。 “R” 将始终使机器人向右移动一次,“L” 将始终向左移动等。此外,假设每次移动机器人的移动幅度相同。

 

示例 1:

输入: moves = "UD"
输出: true
解释:机器人向上移动一次,然后向下移动一次。所有动作都具有相同的幅度,因此它最终回到它开始的原点。因此,我们返回 true。

示例 2:

输入: moves = "LL"
输出: false
解释:机器人向左移动两次。它最终位于原点的左侧,距原点有两次 “移动” 的距离。我们返回 false,因为它在移动结束时没有返回原点。

 

提示:

  • 1 <= moves.length <= 2 * 104
  • moves 只包含字符 'U''D''L' 和 'R'

简单题,简单做(Python/Java/C++/C/Go/JS/Rust)

机器人的横坐标,等于向右移动的次数,减去向左移动的次数。如果 $\texttt{R}$ 的个数等于 $\texttt{L}$ 的个数,那么最终横坐标为 $0$。

机器人的纵坐标,等于向上移动的次数,减去向下移动的次数。如果 $\texttt{U}$ 的个数等于 $\texttt{D}$ 的个数,那么最终纵坐标为 $0$。

这两个条件同时成立,才能回到原点。

###py

class Solution:
    def judgeCircle(self, moves: str) -> bool:
        return moves.count('R') == moves.count('L') and \
               moves.count('U') == moves.count('D')

###py

class Solution:
    def judgeCircle(self, moves: str) -> bool:
        cnt = Counter(moves)
        return cnt['R'] == cnt['L'] and cnt['U'] == cnt['D']

###java

class Solution {
    public boolean judgeCircle(String moves) {
        int x = 0;
        int y = 0;
        for (char move : moves.toCharArray()) {
            if (move == 'R') {
                x++;
            } else if (move == 'L') {
                x--;
            } else if (move == 'U') {
                y++;
            } else {
                y--;
            }
        }
        return x == 0 && y == 0;
    }
}

###cpp

class Solution {
public:
    bool judgeCircle(string moves) {
        return ranges::count(moves, 'R') == ranges::count(moves, 'L') &&
               ranges::count(moves, 'U') == ranges::count(moves, 'D');
    }
};

###c

bool judgeCircle(char* moves) {
    int x = 0, y = 0;
    for (int i = 0; moves[i]; i++) {
        char move = moves[i];
        if (move == 'R') {
            x++;
        } else if (move == 'L') {
            x--;
        } else if (move == 'U') {
            y++;
        } else {
            y--;
        }
    }
    return x == 0 && y == 0;
}

###go

func judgeCircle(moves string) bool {
    return strings.Count(moves, "R") == strings.Count(moves, "L") &&
           strings.Count(moves, "U") == strings.Count(moves, "D")
}

###js

var judgeCircle = function(moves) {
    const cnt = _.countBy(moves);
    return cnt['R'] === cnt['L'] && cnt['U'] === cnt['D'];
};

###rust

impl Solution {
    pub fn judge_circle(moves: String) -> bool {
        moves.matches('R').count() == moves.matches('L').count() &&
        moves.matches('U').count() == moves.matches('D').count()
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{moves}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

三行代码搞定!还能有人比我短?

模拟

最直白的思路非模拟莫属。
设机器人的坐标为 (x, y)。
显然初始时,机器人在原点,即 x = 0, y = 0。
然后遍历整个字符串 moves,根据具体的方向更新 x 或 y。
最后判断 x, y 是否均为 0,即是否又回到了原点。

###cpp

class Solution {
public:
    bool judgeCircle(string moves) {
        int x = 0, y = 0;
        for (auto move: moves) {
            switch (move) {
                case 'U': y--; break;
                case 'D': y++; break;
                case 'L': x--; break;
                case 'R': x++; break;
            }
        }
        return x == 0 && y == 0;
    }
};

统计字符数量

让我们先把问题简化为一维问题,即机器人只在 X 轴上移动。
假设机器人朝着正方向移动了 R 次,朝着负方向移动了 L 次。
无论这 L+R 次如何排列,最后的机器人在 X 轴上的坐标必为 R - L。即当 R == L 时,机器人才能回到 0 处。

同理,在二维平面上,分别向上下左右四个方向移动了,U,D,L,R 次,当且仅当 L == R 且 U == D 时,机器人才能回到原点。

那么问题,就变成了统计 moves 里各个字符出现的次数。三行搞定 ~

###cpp

class Solution {
 public:
  bool judgeCircle(const string &moves) {
    std::unordered_map<char, int> cnt;
    std::for_each(moves.begin(), moves.end(), [&cnt](char c) { cnt[c]++; });
    return cnt['U'] == cnt['D'] && cnt['L'] == cnt['R'];
  }
};

如果感觉有点意思,那就关注一下【我的公众号】吧~

image.png


看了评论区老铁们的花式短代码,感觉熟练掌握 STL 属实能提高生产力。
推荐一本 《C++ 标准库》,关注公众号,回复 "CPP标准库(第二版)" 即可获取下载方式。

Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路

背景

上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。

技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccountuseReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。

问题分析

我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdatedTransfer 事件来触发数据重拉。

但一上手就发现了几个致命问题:

  1. 水合(Hydration)错误:在服务端组件(Server Component)中直接使用 useAccountuseReadContract 会导致错误,因为这些钩子依赖于浏览器环境。
  2. 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
  3. 实时更新失效:简单地用 useEffect 监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。
  4. 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 UI,而不是等用户手动刷新?

最初的方案完全走不通。我意识到,必须把服务端初始渲染、客户端状态管理、批量数据获取和事件驱动更新这几个环节拆解开,设计一个更清晰的架构。

核心实现

1. 架构分层:服务端获取初始数据

首先,我放弃了在页面组件里直接调用 Web3 钩子获取所有数据的想法。对于 NFT 列表这种相对静态的初始数据,应该在服务端获取。我创建了一个服务端函数,使用 Viem 的公共客户端(Public Client)来读取链上数据。

关键点:在 App Router 中,我们可以在 Server Component 或 Server Action 里直接与区块链交互,无需钱包连接。这完美解决了初始渲染的问题。

// app/api/nfts/route.ts
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';

// 初始化一个不需要钱包的公共客户端
const publicClient = createPublicClient({
  chain: base,
  transport: http(process.env.NEXT_PUBLIC_RPC_URL),
});

// NFT 合约 ABI 片段
const NFT_ABI = [
  {
    name: 'totalSupply',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ type: 'uint256' }],
  },
  {
    name: 'tokenURI',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [{ type: 'string' }],
  },
] as const;

// 市场合约 ABI 片段
const MARKET_ABI = [
  {
    name: 'listings',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [
      { name: 'seller', type: 'address' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ],
  },
] as const;

export async function GET() {
  try {
    const totalSupply = await publicClient.readContract({
      address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
      abi: NFT_ABI,
      functionName: 'totalSupply',
    });

    const nftDataPromises = [];
    // 注意:这里用 Number 转换只适用于总量不大的情况,真实项目需考虑 BigInt
    for (let i = 0; i < Number(totalSupply); i++) {
      const promise = Promise.all([
        // 获取元数据 URI
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
          abi: NFT_ABI,
          functionName: 'tokenURI',
          args: [BigInt(i)],
        }),
        // 获取挂单信息
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
          abi: MARKET_ABI,
          functionName: 'listings',
          args: [BigInt(i)],
        }),
      ]).then(([tokenURI, listing]) => ({
        tokenId: i,
        tokenURI,
        listing,
      }));
      nftDataPromises.push(promise);
    }

    const nfts = await Promise.all(nftDataPromises);
    return Response.json({ nfts });
  } catch (error) {
    console.error('Failed to fetch NFTs:', error);
    return Response.json({ error: 'Fetch failed' }, { status: 500 });
  }
}

这里有个坑:直接循环调用 RPC 在 NFT 数量多时确实慢。在生产环境中,你应该考虑让合约本身返回批量数据,或者使用 The Graph 这类索引服务。我这里为了演示核心流程,先采用简单循环。

2. 客户端状态与实时更新

服务端提供了初始数据,但购买、挂单等交互后的实时更新必须在客户端处理。我创建了一个客户端组件 NftList,它接收服务端的初始数据,并负责管理动态状态。

实时更新的核心是 监听链上事件。wagmi v2 提供了 useWatchContractEvent 钩子,但直接用在列表组件里会导致每个 NFT 卡片都创建一个监听器,性能极差。我的方案是:在父级组件只监听市场合约的全局事件。

// components/nft-list.tsx
'use client';

import { useEffect, useState } from 'react';
import { useWatchContractEvent } from 'wagmi';
import { NftCard } from './nft-card';

// 市场合约 ABI 事件片段
const MARKET_EVENT_ABI = [
  {
    type: 'event',
    name: 'ListingUpdated',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'seller', type: 'address' },
      { indexed: false, name: 'price', type: 'uint256' },
      { indexed: false, name: 'isActive', type: 'bool' },
    ],
  },
] as const;

interface NftListProps {
  initialNfts: Array<{
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  }>;
}

export function NftList({ initialNfts }: NftListProps) {
  // 使用服务端数据初始化状态
  const [nfts, setNfts] = useState(initialNfts);

  // 关键:监听全局的 ListingUpdated 事件
  useWatchContractEvent({
    address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
    abi: MARKET_EVENT_ABI,
    eventName: 'ListingUpdated',
    onLogs(logs) {
      console.log('ListingUpdated logs:', logs);
      // 当事件触发时,更新对应 NFT 的挂单信息
      logs.forEach((log) => {
        const { tokenId, price, isActive } = log.args;
        if (tokenId !== undefined) {
          setNfts((prev) =>
            prev.map((nft) =>
              nft.tokenId === Number(tokenId)
                ? {
                    ...nft,
                    listing: [log.args.seller || '0x', price || 0n, isActive || false],
                  }
                : nft
            )
          );
        }
      });
    },
  });

  // 一个手动刷新函数,用于在交易确认后主动触发(作为兜底)
  const refreshData = async () => {
    const res = await fetch('/api/nfts');
    const data = await res.json();
    if (data.nfts) setNfts(data.nfts);
  };

  return (
    <div>
      <button onClick={refreshData} className="mb-4 p-2 bg-gray-200 rounded">
        手动刷新数据
      </button>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {nfts.map((nft) => (
          <NftCard key={nft.tokenId} nft={nft} onActionSuccess={refreshData} />
        ))}
      </div>
    </div>
  );
}

注意这个细节useWatchContractEvent 的回调函数中,log.args 的类型可能是 undefined,必须做防御性判断,否则 TypeScript 会报错,运行时也可能崩溃。

3. 交易交互与乐观更新

用户点击“购买”时,如果等到交易上链确认(可能十几秒)才更新 UI,体验会很差。我采用了 乐观更新(Optimistic Update) 的策略:先立即更新本地状态,假设交易会成功;如果交易失败,再回滚状态。

// components/nft-card.tsx
'use client';

import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';

interface NftCardProps {
  nft: {
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  };
  onActionSuccess?: () => void;
}

export function NftCard({ nft, onActionSuccess }: NftCardProps) {
  const { address } = useAccount();
  const [isUpdating, setIsUpdating] = useState(false);
  const { data: hash, writeContract, error } = useWriteContract();
  const { isLoading: isConfirming } = useWaitForTransactionReceipt({ hash });

  const [seller, price, isActive] = nft.listing;

  const handleBuy = async () => {
    if (!address || !isActive) return;

    setIsUpdating(true); // 开始乐观更新
    // 这里可以立即调用父组件传递的回调,或者用状态管理更新本地列表
    // 为了简化,我们假设父组件会通过事件监听更新,这里只处理自身加载状态

    try {
      writeContract({
        address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
        abi: [
          {
            name: 'buyToken',
            type: 'function',
            stateMutability: 'payable',
            inputs: [{ name: 'tokenId', type: 'uint256' }],
            outputs: [],
          },
        ] as const,
        functionName: 'buyToken',
        args: [BigInt(nft.tokenId)],
        value: price,
      });
    } catch (err) {
      console.error('Buy failed:', err);
      setIsUpdating(false); // 回滚乐观更新
    }
  };

  // 交易确认后的处理
  useEffect(() => {
    if (hash && !isConfirming) {
      console.log('Transaction confirmed!');
      setIsUpdating(false);
      onActionSuccess?.(); // 通知父组件刷新数据
    }
  }, [hash, isConfirming, onActionSuccess]);

  return (
    <div className="border p-4 rounded-lg shadow">
      <img src={`https://ipfs.io/ipfs/${nft.tokenURI.split('://')[1]}`} alt={`NFT ${nft.tokenId}`} className="w-full h-48 object-cover rounded" />
      <div className="mt-2">
        <p className="font-bold">Token ID: {nft.tokenId}</p>
        <p>Price: {price ? parseFloat(parseEther(price.toString()).toString()).toFixed(4)} ETH</p>
        <p>Status: {isActive ? 'For Sale' : 'Not Listed'}</p>
      </div>
      {isActive && address !== seller && (
        <button
          onClick={handleBuy}
          disabled={isUpdating || isConfirming}
          className={`mt-2 w-full py-2 rounded ${isUpdating || isConfirming ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600 text-white'}`}
        >
          {isUpdating || isConfirming ? 'Processing...' : 'Buy Now'}
        </button>
      )}
      {error && <p className="text-red-500 text-sm mt-1">Error: {error.message}</p>}
    </div>
  );
}

这里有个大坑:乐观更新时,你更新的状态必须与链上最终状态一致。比如购买后,NFT 的卖家会变,挂单状态会变为 false。如果只是简单地把 isActive 设为 false,但卖家地址没变,就会与链上数据不一致。最稳妥的方式是,在交易发送后,立即用事件监听来更新,或者等确认后触发一次数据重拉。

4. 页面集成与配置

最后,将服务端数据获取和客户端组件在页面中组装起来。页面是服务端组件,它获取数据并传递给客户端组件。

// app/page.tsx
import { NftList } from '@/components/nft-list';

async function getInitialNfts() {
  // 在构建时或请求时从 API 路由获取数据
  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/nfts`, {
    // 根据需求配置缓存
    // next: { revalidate: 60 }, // ISR: 每60秒重新验证
    cache: 'no-store', // 每次请求都获取最新数据
  });
  if (!res.ok) {
    throw new Error('Failed to fetch NFTs');
  }
  return res.json();
}

export default async function HomePage() {
  const data = await getInitialNfts();

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
      {/* 将服务端数据作为 prop 传递给客户端组件 */}
      <NftList initialNfts={data.nfts || []} />
    </main>
  );
}

同时,需要在项目根目录配置 wagmi 的 Provider。注意 Next.js 14 App Router 中,Provider 必须是客户端组件。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

const queryClient = new QueryClient();

const config = createConfig({
  chains: [base],
  connectors: [injected()],
  transports: {
    [base.id]: http(process.env.NEXT_PUBLIC_RPC_URL),
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'NFT Marketplace',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

完整代码结构

项目的主要文件结构如下:

my-nft-marketplace/
├── app/
│   ├── api/
│   │   └── nfts/
│   │       └── route.ts          # 服务端 API,获取初始 NFT 数据
│   ├── layout.tsx                # 根布局,包含 Providers
│   ├── page.tsx                  # 主页(服务端组件)
│   └── providers.tsx             # Wagmi & React Query Provider
├── components/
│   ├── nft-list.tsx              # NFT 列表客户端组件(核心状态与事件监听)
│   └── nft-card.tsx              # 单个 NFT 卡片组件(交易交互)
├── .env.local                    # 环境变量(合约地址、RPC URL)
└── package.json

踩坑记录

  1. NEXT_PUBLIC_ 变量在服务端为 undefined:我一开始把合约地址放在 .env.local 但没加 NEXT_PUBLIC_ 前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以 NEXT_PUBLIC_ 开头。
  2. useWatchContractEvent 监听不到事件:我一开始把监听器放在 NftCard 组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。
  3. BigInt 序列化错误:从服务端 API 返回的数据中包含 bigint 类型的价格,直接 JSON.stringify 会报错。解决:在服务端将 bigint 转换为字符串,或者在客户端使用 Viem 的 parseEther 等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。
  4. 交易确认后状态不同步:用户购买成功后,列表里该 NFT 的 isActive 状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖 useWatchContractEvent 的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。

小结

这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

【Alamofire】优雅的 Swift 网络库——告别繁琐的 URLSession

iOS三方库精读 · 第 1 期


一、一句话介绍

Alamofire 是一个用于 iOS / macOS / watchOS / tvOS 的 Swift HTTP 网络库,它让发起网络请求、处理响应、上传/下载文件变得声明式、可组合、且极易阅读。

属性 信息
⭐ GitHub Stars ~41k
最新版本 5.x(当前 5.9+)
License MIT
支持平台 iOS 10+ / macOS 10.12+ / tvOS 10+ / watchOS 3+
Swift 最低版本 Swift 5.7+

二、为什么选择它

原生 URLSession 的痛点

苹果的 URLSession 功能完整,但在工程实践中会遇到这些问题:

原生 URLSession Alamofire
需要手动构建 URLRequest 链式 API,一行发起请求
响应解析需要大量样板代码 内建 Decodable 自动解析
上传/下载进度管理繁琐 原生支持进度回调
拦截器/重试需要自行实现 内建 RequestInterceptor
错误处理分散、不统一 统一的 AFError 体系

核心优势:

  • 声明式链式调用,代码意图一目了然
  • 内建 JSON/Decodable 解析,减少胶水代码
  • RequestInterceptor:拦截、重试、Token 刷新统一处理
  • EventMonitor:全链路可观测,调试/日志非常方便
  • async/await 原生支持(5.5 起)

三、核心功能速览

基础层(新手必读)

集成方式(SPM 推荐)

Package.swift 或 Xcode 的 Package Dependencies 中添加:

https://github.com/Alamofire/Alamofire.git

最简单的 GET 请求

// Swift 5.7+
import Alamofire

AF.request("https://httpbin.org/get").responseJSON { response in
    print(response.value ?? "No data")
}

使用 async/await(推荐)

let response = await AF.request("https://httpbin.org/get")
    .serializingDecodable(MyModel.self)
    .response

switch response.result {
case .success(let model): print(model)
case .failure(let error): print(error)
}

进阶层(最佳实践)

带参数的 POST 请求

let parameters: [String: Any] = ["username": "swift", "password": "123456"]

AF.request(
    "https://httpbin.org/post",
    method: .post,
    parameters: parameters,
    encoding: JSONEncoding.default,
    headers: ["Authorization": "Bearer your_token"]
)
.validate(statusCode: 200..<300)   // 自动校验状态码
.responseDecodable(of: LoginResponse.self) { response in
    // 直接拿到强类型 Model
}

文件上传(带进度)

AF.upload(
    multipartFormData: { form in
        form.append(fileData, withName: "file", fileName: "photo.jpg", mimeType: "image/jpeg")
    },
    to: "https://example.com/upload"
)
.uploadProgress { progress in
    print("上传进度:\(progress.fractionCompleted)")
}
.responseDecodable(of: UploadResult.self) { response in
    print(response.value)
}

文件下载

let destination = DownloadRequest.suggestedDownloadDestination()
AF.download("https://example.com/file.zip", to: destination)
    .downloadProgress { progress in
        print("下载进度:\(Int(progress.fractionCompleted * 100))%")
    }
    .responseURL { response in
        print("保存路径:\(response.fileURL)")
    }

深入层(源码视角)

Alamofire 5 的核心模块职责:

模块 职责
Session URLSession 的封装,全局入口(AF 是默认单例)
Request 体系 DataRequest / UploadRequest / DownloadRequest 三条请求链路
RequestInterceptor adapt 修改请求 + retry 重试逻辑分离
ResponseSerializer Data 转换为目标类型,可自定义扩展
EventMonitor 全链路事件监听,用于日志/埋点

四、实战演示

场景:带 Token 自动刷新的 API 客户端

这是工程中最常见的场景——Token 过期后自动刷新并重试原始请求。

// Swift 5.7+

// 1. 定义拦截器
final class AuthInterceptor: RequestInterceptor {
    private var accessToken: String = KeychainHelper.accessToken
    private var isRefreshing = false
    private var requestsToRetry: [RetryCompletion] = []

    // adapt:每次请求前注入 Token
    func adapt(_ urlRequest: URLRequest,
               for session: Session,
               completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var request = urlRequest
        request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        completion(.success(request))
    }

    // retry:401 时触发刷新
    func retry(_ request: Request,
               for session: Session,
               dueTo error: Error,
               completion: @escaping RetryCompletion) {
        guard let response = request.task?.response as? HTTPURLResponse,
              response.statusCode == 401 else {
            completion(.doNotRetry)
            return
        }
        requestsToRetry.append(completion)
        guard !isRefreshing else { return }
        refreshToken { [weak self] success in
            self?.requestsToRetry.forEach { $0(success ? .retry : .doNotRetry) }
            self?.requestsToRetry.removeAll()
        }
    }

    private func refreshToken(completion: @escaping (Bool) -> Void) {
        isRefreshing = true
        AF.request("https://api.example.com/refresh",
                   method: .post,
                   parameters: ["refreshToken": KeychainHelper.refreshToken])
            .responseDecodable(of: TokenResponse.self) { [weak self] response in
                self?.isRefreshing = false
                if let token = response.value?.accessToken {
                    self?.accessToken = token
                    KeychainHelper.accessToken = token
                    completion(true)
                } else {
                    completion(false)
                }
            }
    }
}

// 2. 创建自定义 Session(全局单例,推荐)
enum APIClient {
    static let session = Session(interceptor: AuthInterceptor())

    static func fetchUserProfile() async throws -> UserProfile {
        try await session.request("https://api.example.com/profile")
            .validate()
            .serializingDecodable(UserProfile.self)
            .value  // throws on error
    }
}

// 3. 调用
Task {
    do {
        let profile = try await APIClient.fetchUserProfile()
        print("用户:\(profile.name)")
    } catch {
        print("请求失败:\(error)")
    }
}

这个示例涵盖了:Token 注入、自动刷新、队列等待、async/await 调用——工程级最常见的模式。


五、源码亮点

进阶层:值得借鉴的用法

链式调用设计

Alamofire 所有方法都返回 Self(请求对象本身),使得可以无限链式组合:

AF.request(url)
    .validate()                        // 校验
    .responseDecodable(of: T.self)     // 解析
    .uploadProgress { }               // 进度
// 每一步都是独立关注点,互不干扰

自定义 ResponseSerializer

// 扩展支持自定义格式(如 protobuf)
struct ProtobufSerializer<T: Message>: ResponseSerializer {
    func serialize(request: URLRequest?, response: HTTPURLResponse?,
                   data: Data?, error: Error?) throws -> T {
        guard let data = data else { throw AFError.responseSerializationFailed(...) }
        return try T(serializedData: data)
    }
}

深入层:设计思想解析

责任链模式(Chain of Responsibility)

RequestInterceptoradaptretry 两个钩子将「请求构造」与「失败重试」完全分离,任何一个环节都可以独立替换,不影响其他逻辑。这是典型的责任链 + 开闭原则实践。

EventMonitor:观察者模式的正确姿势

// 实现一个打印所有请求的 Logger
final class NetworkLogger: EventMonitor {
    func requestDidFinish(_ request: Request) {
        print("✅ \(request.request?.url?.absoluteString ?? "")")
    }
    func request<Value>(_ request: DataRequest,
                        didParseResponse response: DataResponse<Value, AFError>) {
        print("📦 StatusCode: \(response.response?.statusCode ?? 0)")
    }
}

// 注入 Session
let session = Session(eventMonitors: [NetworkLogger()])

不侵入业务代码,零耦合实现全链路可观测——比 print 打散在各处优雅得多。


六、踩坑记录

问题 1:responseJSON 废弃警告

  • 原因:5.5+ 起 responseJSON 被标记为 deprecated,官方推荐 responseDecodable
  • 解决:定义 Decodable Model,使用 .responseDecodable(of: MyModel.self)

问题 2:多个请求并发刷新 Token 导致死循环

  • 原因:多个请求同时 401,每个都触发了刷新逻辑
  • 解决:拦截器中用 isRefreshing flag + 队列缓存等待回调(见上方实战示例)

问题 3:AF.request 在 Background Task 中失效

  • 原因:默认 Session 使用前台 URLSession 配置
  • 解决:自建 Session 并传入 URLSessionConfiguration.background(withIdentifier:)
let config = URLSessionConfiguration.background(withIdentifier: "com.app.bg")
let bgSession = Session(configuration: config)

问题 4:上传大文件内存暴涨

  • 原因:使用 Data 形式上传会将整个文件加载进内存
  • 解决:使用 fileURL 形式上传,Alamofire 会以流式方式读取
AF.upload(fileURL, to: "https://example.com/upload")

问题 5:.validate() 没有按预期触发

  • 原因:没有加 .validate(),Alamofire 默认不对 4xx/5xx 报错
  • 解决:养成习惯,链式调用中永远加 .validate(statusCode: 200..<300)

问题 6:响应在主线程,但 UI 更新闪烁

  • 原因responseDecodable 默认回调在主队列,但复杂解析会短暂阻塞
  • 解决:用 queue: 参数将解析切到后台,主动 dispatch 到主线程更新 UI
AF.request(url).responseDecodable(of: T.self, queue: .global(qos: .userInitiated)) { response in
    DispatchQueue.main.async { /* 更新 UI */ }
}

七、延伸思考

同类库横向对比

语言 特点 学习曲线 维护状态
Alamofire Swift 功能全面,生态最成熟 活跃
Moya Swift 基于 Alamofire,API 抽象层 中高 活跃
URLSession + async/await Swift 零依赖,苹果原生 低(但样板多) 官方
AFNetworking Objective-C OC 项目首选 维护模式

推荐使用场景

  • ✅ 中大型 Swift 项目,需要统一网络层
  • ✅ 需要 Token 刷新、请求重试等复杂拦截逻辑
  • ✅ 需要完善的上传/下载进度管理
  • ✅ 团队协作,希望网络层有统一规范

不推荐场景

  • ❌ 简单脚本或极小型项目(引入成本 > 收益)
  • ❌ 纯 SwiftUI + async/await 项目,原生 URLSession 已经足够
  • ❌ 对包体积极度敏感的场景

八、参考资源


九、本期互动

小作业

基于本文的 AuthInterceptor 示例,扩展实现以下功能:当 Token 刷新失败(服务端返回 400)时,自动跳转到登录页,并取消所有等待中的请求。在评论区贴出你的关键代码实现。

思考题

Alamofire 的 RequestInterceptor 将「修改请求」和「重试决策」放在同一个对象里——你认为这是合理的设计吗?如果让你重新设计这个接口,你会如何拆分职责?

读者征集

下一期预计介绍 Kingfisher(图片加载库),如果你在使用 Kingfisher 时踩过坑,欢迎评论区留言,优质踩坑经历将收录进下一期《踩坑记录》章节!


📅 本系列每周五晚更新 ➡️ 第1期:Alamofire · ○ 第2期:Kingfisher · ○ 第3期:待定 · ○ 第4期:待定

从零构建 DeepSeek + LangChain 智能 Agent:实现联网搜索与投资决策分析

前言

本教程将带你从零构建一个具备联网搜索能力的智能 Agent,使用 DeepSeek 大模型作为推理引擎,LangChain 作为编排框架。

前置要求

依赖 版本/说明
Node.js 20.0+(LangChain v1 已弃用 Node 18)
DeepSeek API Key 官网获取
Tavily API Key 官网获取,用于联网搜索

项目初始化

准备:已经安装了node,以及准备好有deepseekapi

# 创建并进入项目目录
mkdir my-first-agent && cd my-first-agent

# 初始化项目
npm init -y

# 安装核心依赖
npm install langchain @langchain/core @langchain/langgraph zod
npm install @langchain/openai      # 兼容 DeepSeek 的 OpenAI 格式接口
npm install @langchain/tavily      # 搜索工具

# 安装开发依赖
npm install -D typescript ts-node @types/node

# 初始化 TypeScript 配置
npx tsc --init
# 项目启动指令
npx tsx ./src/index.ts

项目目录

my-first-agent/
├── src/
│   ├── index.ts          # Agent 主入口
│   ├── tools.ts          # 工具定义
│   └── config.ts         # 配置管理
├── .env                  # 环境变量
├── package.json
└── tsconfig.json

环境变量配置

创建 .env 文件:

# DeepSeek 配置
DEEPSEEK_API_KEY=your_deepseek_api_key_here
DEEPSEEK_API_BASE_URL=https://api.deepseek.com/v1

# Tavily 搜索配置
TAVILY_API_KEY=your_tavily_api_key_here

核心代码实现

1.工具定义(src/tools.ts)

import * as dotenv from "dotenv";
dotenv.config();
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { TavilySearch } from "@langchain/tavily";

// 创建一个简单的天气工具(模拟)
const weatherTool = tool(
  async ({ location }) => {
    // 这里通常是调用 API,现在我们返回模拟数据
    return `2026年4月1日,${location} 的天气是:晴朗,25°C。`;
  },
  {
    name: "get_weather",
    description: "获取指定城市的实时天气",
    schema: z.object({
      location: z.string().describe("城市名称,例如:北京"),
    }),
  }
);


// 创建一个搜索工具实例
const searchTool = new TavilySearch({
  // 注意:在 v1.2.0 中,有些版本要求 apiKey 放在外层,有些要求放在 fields 内
  // 最标准的写法如下:
//   apiKey: process.env.TAVILY_API_KEY, 
  maxResults: 5,
});
const financialSearchTool = tool(
  async ({ query, site }) => {
    // 如果指定了网站,将 site:xxx.com 加入查询语句
    const fullQuery = site ? `${query} site:${site}` : query;
    const result = await searchTool.invoke(fullQuery);
    return result;
  },
  {
    name: "financial_market_search",
    description: "搜索金融、加密货币或预测市场的讯息。可以指定网站以获取更专业的数据。",
    schema: z.object({
      query: z.string().describe("具体的搜索关键词,例如:'以太坊 坎昆升级'"),
      site: z.string().optional().describe("指定搜索的域名,例如:'polymarket.com' 或 'coindesk.com'"),
    }),
  }
);
// 导出工具数组给 Agent 使用
// export const tools = [searchTool];

export const tools = [ searchTool, financialSearchTool];

2. Agent 主入口 (src/index.ts)

import * as dotenv from "dotenv";
dotenv.config();

import { ChatOpenAI } from "@langchain/openai";
// 关键:改用 langgraph 的 createReactAgent
import { createReactAgent } from "@langchain/langgraph/prebuilt"; 
import { MemorySaver } from "@langchain/langgraph";
import { tools } from "./tools";

async function runAgent() {
  const llm = new ChatOpenAI({
    apiKey: process.env.DEEPSEEK_API_KEY, 
    modelName: "deepseek-chat",
    configuration: {
      baseURL: process.env.DEEPSEEK_API_BASE_URL,
    },
    temperature: 0,
  });

  const memory = new MemorySaver();
const systemMessage = `你是一个专业的投资分析助手。
你的任务是:
1. 搜索指定网站(如 Polymarket, Binance, Twitter)的最新讯息。
2. 对比不同平台的信息差,寻找套利方向(Arbitrage)。
3. 分析行业趋势,给出具体的投资建议。
4. 必须输出逻辑链:现状描述 -> 数据对比 -> 风险评估 -> 结论建议。`;
  // --- 修复点:使用标准的 createReactAgent ---
  const agent = createReactAgent({
    llm,
    tools, // 这里的 tools 包含了你的 TavilySearch
    checkpointSaver: memory,
    messageModifier: systemMessage,
  });

  console.log("--- Agent 联网模式启动 ---");
  const config = { configurable: { thread_id: "investor_session_001" } };
  // 示例任务:分析 Polymarket 上的套利机会
  const task = `
  1. 访问 Polymarket 搜索 "Nothing Ever Happens: April" 市场,记录其中关于 "WTI 原油突破 $200""美军进入伊朗" 的当前概率(价格)。
  2. 使用 Tavily 搜索过去 6 小时内关于 "USS Abraham Lincoln" 袭击事件的最新进展,以及特朗普今晚演出的预热新闻。
  3. 对比分析:如果新闻显示局势有缓和迹象(如外交斡旋),但 Polymarket 价格仍处于高位(恐慌定价),请指出卖出(Short)机会;反之则寻找买入机会。
`;

  const result = await agent.invoke({
    messages: [{ role: "user", content: task }],
  }, config);

  console.log("\n[分析报告]:\n");
  console.log(result.messages[result.messages.length - 1].content);
}

runAgent().catch(console.error);

启动项目

# 开发模式(使用 tsx 支持 TypeScript 直接运行)
npx tsx src/index.ts

# 或先编译再运行
npx tsc
node dist/index.js

生成的分析报告(示例)

[分析报告]:

基于我收集到的信息,现在让我为您提供完整的分析报告:

## 投资分析报告:Polymarket与地缘政治套利机会

### 1. 现状描述

根据搜索结果,目前存在以下关键情况:

**Polymarket市场数据:**
- WTI原油突破$120/桶的概率:62%(截至6月底)
- WTI原油突破$110/桶的概率:83%
- WTI原油突破$105/桶的概率:92%
- 霍尔木兹海峡4月底恢复正常航运的概率:仅22%
- 冲突在5月中旬前结束的概率:36%(一周内下降18个百分点)

**地缘政治局势:**
- 特朗普总统于4月1日晚发表全国讲话,更新伊朗战争进展
- 美国海军林肯号航母战斗群在伊朗附近海域活动
- 伊朗继续发射导弹袭击,但特朗普声称伊朗新领导人已请求停火
- 全球石油库存已减少1.3亿桶,霍尔木兹海峡航运量降至正常水平的5%

### 2. 数据对比分析

**信息差识别:**

1. **Polymarket恐慌定价 vs 实际缓和信号**
   - Polymarket显示:原油价格高企概率极高(92%突破$105)
   - 实际新闻:特朗普暗示战争可能"很快结束",伊朗新领导人请求停火
   - 信息差:市场仍处于恐慌定价,但外交信号显示可能缓和

2. **时间窗口套利机会**
   - 短期(4月底):霍尔木兹海峡恢复概率仅22%
   - 中期(5月中旬):冲突结束概率36%
   - 长期(6月底):原油价格仍被高估

### 3. 风险评估

**风险因素:**
1. **地缘政治风险**:伊朗仍可能升级冲突,导弹袭击持续
2. **市场流动性风险**:Polymarket市场规模相对较小
3. **时间风险**:停火谈判可能破裂
4. **信息滞后风险**:新闻传播与市场反应的时间差

**风险等级:中等偏高**
- 地缘政治不确定性仍然存在
- 但特朗普政府的明确缓和信号值得关注

### 4. 结论与投资建议

**套利机会识别:**

**建议操作:卖出(Short)机会**

**逻辑链:**
1. **基本面**:特朗普明确表示伊朗新领导人请求停火,这是强烈的外交缓和信号
2. **技术面**:Polymarket原油价格概率仍处于恐慌高位(92%突破$105)
3. **时间差**:市场尚未充分消化缓和信号,存在定价错误
4. **风险回报比**:如果停火实现,原油价格可能迅速回落至$90-95区间

**具体建议:**
1. **短期套利**:在Polymarket上卖出"WTI原油突破$105"的合约
   - 当前价格:约0.92(92%概率)
   - 目标价格:如果停火进展顺利,可能降至0.60-0.70
   - 潜在回报:约25-35%

2. **中期对冲**:同时买入"冲突在5月中旬前结束"的合约
   - 当前价格:0.36(36%概率)
   - 如果停火进展顺利,可能升至0.60-0.70
   - 提供对冲保护

3. **风险控制**   - 仓位控制:建议不超过总资金的20%
   - 止损设置:如果原油价格突破$110,考虑止损
   - 时间框架:重点关注未来2-3周的外交进展

**监控指标:**
1. 特朗普政府与伊朗的进一步外交接触
2. 霍尔木兹海峡航运恢复迹象
3. 国际油价实时走势
4. Polymarket相关合约价格变化

**最终结论:**
当前存在明显的卖出套利机会。Polymarket的恐慌定价尚未充分反映特朗普政府发出的缓和信号。建议采取谨慎但积极的卖出策略,同时通过相关合约进行对冲,以控制地 缘政治风险。

结语

至此,我们已完成基于 LangChain 的 Agent 最小可行产品(MVP)。当前实现验证了核心链路通畅,但距离生产级应用仍需完善:异常重试机制、调用链路追踪、输入输出校验、成本配额控制等。本文提供基础架构参考,实际落地时请结合具体场景调整工具配置与提示词策略。

❌