前端AI工程化(五):AI对话状态管理

长对话状态管理的架构设计

开篇:你以为的状态管理 vs AI对话的状态管理

传统的状态管理解决的是"数据在组件间共享"的问题。AI长对话场景下的状态管理,面对的是完全不同量级的挑战:

arduino 复制代码
// 一个50轮对话的会话,每轮平均500字
// 消息列表DOM节点数:50 × ~50 = ~2500个节点
// 流式输出期间每秒更新50次
// 用户切换会话时需要快照恢复

// 你要管理的不只是"数据",而是:
// 1. 三种状态阶段的生命周期
// 2. 2500+个DOM节点的渲染性能
// 3. 上下文窗口的Token预算
// 4. 多会话的切换与持久化

这不是加一个Redux就能解决的事。

一、长对话的三个状态阶段

每一条AI消息都有三种状态,而传统聊天(如即时通讯)只有两种:

复制代码
传统聊天:发送中 → 已发送

AI对话:输入中 → 流式输出中 → 输出完成

"流式输出中"这个中间状态,是AI对话状态管理的核心复杂性来源:

ini 复制代码
enum MessageStatus {
  PENDING = 'pending',       // 用户已发送,等待AI响应
  STREAMING = 'streaming',   // AI正在流式输出
  PAUSED = 'paused',         // 用户暂停了生成
  COMPLETED = 'completed',   // 生成完成
  ERROR = 'error',           // 生成失败
}

interface AIMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  status: MessageStatus;
  model?: string;
  tokenCount?: number;
  createdAt: number;

  // 流式输出特有的状态
  streamingContent?: string;    // 当前正在流式输出的内容(可能与content不同步)
  isStopRequested?: boolean;    // 用户是否请求了停止

  // 元数据
  metadata?: {
    finishReason?: string;      // 'stop' | 'length' | 'function_call'
    functionCall?: FunctionCall;
    usage?: {
      promptTokens: number;
      completionTokens: number;
    };
  };
}

关键设计决策content vs streamingContent

csharp 复制代码
// 方案A:content就是流式内容,每次追加都更新
// 问题:组件每次content变化都重渲染,性能差

// 方案B(推荐):content只在完成时更新,streamingContent实时追加
// 优势:流式输出只触发StreamingRenderer的重渲染,不影响其他组件
interface AIMessage {
  content: string;           // 最终内容(完成后才更新)
  streamingContent?: string; // 流式内容(实时追加)
}

二、对话状态领域模型

typescript 复制代码
// === 会话(Session)===
interface ChatSession {
  id: string;
  title: string;
  messages: AIMessage[];
  createdAt: number;
  updatedAt: number;

  // 会话级状态
  status: SessionStatus;
  currentModel: string;

  // Token预算
  tokenUsage: {
    promptTokens: number;
    completionTokens: number;
    totalTokens: number;
  };

  // 配置
  config: {
    systemPrompt?: string;
    temperature?: number;
    maxTokens?: number;
  };
}

enum SessionStatus {
  IDLE = 'idle',             // 空闲,等待用户输入
  GENERATING = 'generating', // AI正在生成
  ERROR = 'error',           // 错误状态
}

// === 会话管理器(SessionManager)===
interface SessionManager {
  // 会话CRUD
  createSession(): ChatSession;
  deleteSession(id: string): void;
  switchSession(id: string): void;

  // 当前会话
  activeSessionId: string | null;

  // 消息操作
  sendMessage(content: string): Promise<void>;
  stopGeneration(): void;
  regenerateMessage(messageId: string): Promise<void>;
  editMessage(messageId: string, newContent: string): Promise<void>;
}

三、消息列表虚拟化:只渲染可视区域

50轮对话 = 100条消息 = ~2500个DOM节点。如果每条消息都渲染完整的Markdown(含代码块、表格),DOM节点数轻松破万。

虚拟滚动的核心思想:只渲染用户能看到的10-15条消息,其余用空白占位。

typescript 复制代码
// 虚拟滚动的关键参数
interface VirtualScrollConfig {
  overscan: number;        // 可视区域上下额外渲染的条数(默认5)
  estimatedItemHeight: number; // 每条消息的预估高度(用于计算滚动条)
  scrollToBottomOnNewMessage: boolean; // 新消息时自动滚到底部
}

// 消息高度的计算挑战
// AI消息高度不确定:50字的消息可能只有1行,5000字的消息可能占满整个屏幕
// 代码块、表格的高度更难预估

// 策略:先按预估高度布局,实际渲染后修正
class MessageHeightCache {
  private heights = new Map<string, number>();
  private estimatedHeight = 100; // 默认预估高度

  getHeight(messageId: string): number {
    return this.heights.get(messageId) ?? this.estimatedHeight;
  }

  setHeight(messageId: string, height: number): void {
    this.heights.set(messageId, height);
  }

  updateEstimatedHeight(): void {
    if (this.heights.size === 0) return;
    const sum = Array.from(this.heights.values()).reduce((a, b) => a + b, 0);
    this.estimatedHeight = sum / this.heights.size;
  }
}

四、上下文窗口裁剪

LLM有上下文窗口限制,对话过长时必须裁剪历史消息。前端需要管理裁剪策略:

ini 复制代码
type TrimStrategy =
  | { type: 'sliding_window'; maxMessages: number }
  | { type: 'token_budget'; maxTokens: number }
  | { type: 'summary'; summaryModel: string };

class ContextWindowManager {
  private strategy: TrimStrategy;

  constructor(strategy: TrimStrategy) {
    this.strategy = strategy;
  }

  /** 裁剪消息列表,确保不超出上下文窗口 */
  trim(messages: AIMessage[]): AIMessage[] {
    // 始终保留System Prompt
    const systemMsg = messages.filter(m => m.role === 'system');

    // 非System消息
    const chatMessages = messages.filter(m => m.role !== 'system');

    switch (this.strategy.type) {
      case 'sliding_window':
        return [
          ...systemMsg,
          ...chatMessages.slice(-this.strategy.maxMessages),
        ];

      case 'token_budget': {
        // 从最新消息开始保留,直到接近Token预算
        const budget = this.strategy.maxTokens;
        let tokenCount = 0;
        const kept: AIMessage[] = [];

        for (let i = chatMessages.length - 1; i >= 0; i--) {
          const msgTokens = this.estimateTokens(chatMessages[i].content);
          if (tokenCount + msgTokens > budget) break;
          tokenCount += msgTokens;
          kept.unshift(chatMessages[i]);
        }

        return [...systemMsg, ...kept];
      }

      case 'summary': {
        // 保留最近10轮 + 对更早内容的摘要
        const recent = chatMessages.slice(-20);
        const oldMessages = chatMessages.slice(0, -20);

        if (oldMessages.length === 0) {
          return [...systemMsg, ...recent];
        }

        // 生成摘要(实际会调用LLM)
        const summaryMsg: AIMessage = {
          id: 'summary',
          role: 'system',
          content: `[以下是之前对话的摘要]\n${this.generateSummary(oldMessages)}`,
          status: MessageStatus.COMPLETED,
          createdAt: oldMessages[0].createdAt,
        };

        return [...systemMsg, summaryMsg, ...recent];
      }
    }
  }

  private estimateTokens(text: string): number {
    const chineseChars = (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
    const otherChars = text.length - chineseChars;
    return Math.ceil(chineseChars * 1.5 + otherChars * 0.25);
  }

  private generateSummary(messages: AIMessage[]): string {
    // 实际项目中调用LLM生成摘要
    return messages
      .map(m => `${m.role}: ${m.content.slice(0, 100)}...`)
      .join('\n');
  }
}

五、多会话管理:切换时的状态快照与恢复

php 复制代码
interface SessionSnapshot {
  sessionId: string;
  scrollPosition: number;
  inputText: string;          // 未发送的输入框内容
  selectedModel: string;
  timestamp: number;
}

class SessionSwitchManager {
  private snapshots = new Map<string, SessionSnapshot>();

  /** 切换会话前保存当前状态快照 */
  saveSnapshot(sessionId: string, state: {
    scrollPosition: number;
    inputText: string;
    selectedModel: string;
  }): void {
    this.snapshots.set(sessionId, {
      sessionId,
      scrollPosition: state.scrollPosition,
      inputText: state.inputText,
      selectedModel: state.selectedModel,
      timestamp: Date.now(),
    });
  }

  /** 切换到目标会话时恢复状态 */
  restoreSnapshot(sessionId: string): SessionSnapshot | null {
    return this.snapshots.get(sessionId) ?? null;
  }
}

六、状态管理库选型对比

结论:React项目选Zustand,Vue项目选Pinia。不要在AI对话场景中强行用Redux------它太重了。

实践任务

任务:设计一个AI对话状态管理的TypeScript接口定义,涵盖消息、会话、流式状态三大领域模型。

要求

  1. 完整的TypeScript接口/类型/枚举定义
  2. 消息状态机的合法转换关系图
  3. 会话操作的方法签名(发送/停止/重新生成/编辑)
  4. 虚拟滚动配置接口
  5. 上下文裁剪策略接口

面试题解析

Q:在React/Vue中如何优雅地管理AI长对话的状态?

答题要点

  1. 核心挑战:消息列表膨胀、流式状态过渡、上下文窗口裁剪、多会话切换
  2. 流式状态:content与streamingContent分离,避免全量重渲染
  3. 虚拟滚动:只渲染可视区域的消息,预估高度+实际修正
  4. 上下文裁剪:滑动窗口/Token预算/摘要压缩三种策略
  5. 多会话:切换时保存快照(输入框内容、滚动位置),恢复时重建
  6. 选型:React选Zustand(selector细粒度订阅),Vue选Pinia(reactivity天然适配)

5.2 React/Vue长对话状态管理实战

开篇:从接口到实现

上一期我们设计了AI对话状态管理的领域模型和接口。这一期,我们分别用React(Zustand)和Vue(Pinia)来实现。

一、React + Zustand实现

1.1 Store设计:Slice模式

typescript 复制代码
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { subscribeWithSelector } from 'zustand/middleware';

// === 消息Slice ===
interface MessageSlice {
  messages: AIMessage[];
  addMessage: (message: AIMessage) => void;
  updateMessage: (id: string, updates: Partial<AIMessage>) => void;
  appendStreamingContent: (id: string, token: string) => void;
  completeMessage: (id: string, finalContent: string) => void;
  removeMessage: (id: string) => void;
}

const createMessageSlice: StateCreator<ChatStore, [], [], MessageSlice> = (set, get) => ({
  messages: [],

  addMessage: (message) => set((state) => ({
    messages: [...state.messages, message],
  })),

  updateMessage: (id, updates) => set((state) => ({
    messages: state.messages.map(msg =>
      msg.id === id ? { ...msg, ...updates } : msg
    ),
  })),

  // 核心优化:appendStreamingContent只更新streamingContent,不触发其他组件重渲染
  appendStreamingContent: (id, token) => set((state) => ({
    messages: state.messages.map(msg =>
      msg.id === id
        ? { ...msg, streamingContent: (msg.streamingContent ?? '') + token }
        : msg
    ),
  })),

  completeMessage: (id, finalContent) => set((state) => ({
    messages: state.messages.map(msg =>
      msg.id === id
        ? {
            ...msg,
            content: finalContent,
            streamingContent: undefined,
            status: MessageStatus.COMPLETED,
          }
        : msg
    ),
  })),

  removeMessage: (id) => set((state) => ({
    messages: state.messages.filter(msg => msg.id !== id),
  })),
});

// === 会话Slice ===
interface SessionSlice {
  sessions: ChatSession[];
  activeSessionId: string | null;
  sessionStatus: SessionStatus;

  createSession: () => string;
  switchSession: (id: string) => void;
  deleteSession: (id: string) => void;
  setSessionStatus: (status: SessionStatus) => void;
}

const createSessionSlice: StateCreator<ChatStore, [], [], SessionSlice> = (set, get) => ({
  sessions: [],
  activeSessionId: null,
  sessionStatus: SessionStatus.IDLE,

  createSession: () => {
    const id = `session-${Date.now()}`;
    const session: ChatSession = {
      id,
      title: '新对话',
      messages: [],
      createdAt: Date.now(),
      updatedAt: Date.now(),
      status: SessionStatus.IDLE,
      currentModel: 'gpt-4',
      tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
      config: {},
    };

    set((state) => ({
      sessions: [...state.sessions, session],
      activeSessionId: id,
    }));

    return id;
  },

  switchSession: (id) => set({ activeSessionId: id }),

  deleteSession: (id) => set((state) => ({
    sessions: state.sessions.filter(s => s.id !== id),
    activeSessionId: state.activeSessionId === id
      ? (state.sessions[0]?.id ?? null)
      : state.activeSessionId,
  })),

  setSessionStatus: (status) => set({ sessionStatus: status }),
});

// === 组合Store ===
type ChatStore = MessageSlice & SessionSlice & SSESlice;

const useChatStore = create<ChatStore>()(
  subscribeWithSelector(
    persist(
      (...a) => ({
        ...createMessageSlice(...a),
        ...createSessionSlice(...a),
      }),
      {
        name: 'ai-chat-store',
        storage: createJSONStorage(() => localStorage),
        // 只持久化部分数据
        partialize: (state) => ({
          sessions: state.sessions.map(s => ({
            ...s,
            messages: s.messages.map(m => ({
              ...m,
              streamingContent: undefined, // 不持久化流式内容
              status: m.status === MessageStatus.STREAMING
                ? MessageStatus.COMPLETED
                : m.status,
            })),
          })),
          activeSessionId: state.activeSessionId,
        }),
      }
    )
  )
);

1.2 细粒度订阅:避免不必要的重渲染

javascript 复制代码
// ❌ 错误用法:订阅整个messages数组
const messages = useChatStore(state => state.messages);
// 每次任何message更新都会触发重渲染

// ✅ 正确用法:只订阅需要的数据
const messageCount = useChatStore(state => state.messages.length);

// ✅ 只订阅特定消息的streamingContent
function useStreamingContent(messageId: string): string | undefined {
  return useChatStore(
    subscribeWithSelector(
      state => state.messages.find(m => m.id === messageId)?.streamingContent
    )
  );
}

// ✅ 只订阅当前会话的消息
function useActiveMessages(): AIMessage[] {
  const activeSessionId = useChatStore(state => state.activeSessionId);
  return useChatStore(
    state => state.sessions.find(s => s.id === activeSessionId)?.messages ?? []
  );
}

二、Vue + Pinia实现

2.1 Store设计:组合式API

ini 复制代码
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';

export const useChatStore = defineStore('chat', () => {
  // === 状态 ===
  const sessions = ref<ChatSession[]>([]);
  const activeSessionId = ref<string | null>(null);
  const sessionStatus = ref<SessionStatus>(SessionStatus.IDLE);

  // === 计算属性 ===
  const activeSession = computed(() =>
    sessions.value.find(s => s.id === activeSessionId.value) ?? null
  );

  const activeMessages = computed(() =>
    activeSession.value?.messages ?? []
  );

  const isGenerating = computed(() =>
    sessionStatus.value === SessionStatus.GENERATING
  );

  // === 操作 ===
  function createSession(): string {
    const id = `session-${Date.now()}`;
    sessions.value.push({
      id,
      title: '新对话',
      messages: [],
      createdAt: Date.now(),
      updatedAt: Date.now(),
      status: SessionStatus.IDLE,
      currentModel: 'gpt-4',
      tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
      config: {},
    });
    activeSessionId.value = id;
    return id;
  }

  function addMessage(message: AIMessage): void {
    activeSession.value?.messages.push(message);
  }

  function appendStreamingContent(messageId: string, token: string): void {
    const msg = activeMessages.value.find(m => m.id === messageId);
    if (msg) {
      msg.streamingContent = (msg.streamingContent ?? '') + token;
    }
  }

  function completeMessage(messageId: string, finalContent: string): void {
    const msg = activeMessages.value.find(m => m.id === messageId);
    if (msg) {
      msg.content = finalContent;
      msg.streamingContent = undefined;
      msg.status = MessageStatus.COMPLETED;
    }
  }

  function switchSession(id: string): void {
    activeSessionId.value = id;
  }

  function deleteSession(id: string): void {
    const index = sessions.value.findIndex(s => s.id === id);
    if (index !== -1) {
      sessions.value.splice(index, 1);
      if (activeSessionId.value === id) {
        activeSessionId.value = sessions.value[0]?.id ?? null;
      }
    }
  }

  // === 持久化 ===
  // 使用 pinia-plugin-persistedstate
  // 配置在defineStore的第三个参数中

  return {
    sessions,
    activeSessionId,
    sessionStatus,
    activeSession,
    activeMessages,
    isGenerating,
    createSession,
    addMessage,
    appendStreamingContent,
    completeMessage,
    switchSession,
    deleteSession,
  };
}, {
  persist: {
    key: 'ai-chat-store',
    storage: localStorage,
    pick: ['sessions', 'activeSessionId'],
    // 自定义序列化:不持久化流式内容
    serializer: {
      serialize: (state) => JSON.stringify({
        ...state,
        sessions: (state as any).sessions?.map((s: ChatSession) => ({
          ...s,
          messages: s.messages.map(m => ({
            ...m,
            streamingContent: undefined,
            status: m.status === MessageStatus.STREAMING
              ? MessageStatus.COMPLETED
              : m.status,
          })),
        })),
      }),
      deserialize: JSON.parse,
    },
  },
});

2.2 Vue的reactivity天然优势

Vue的响应式系统基于Proxy,对对象的局部修改天然只触发依赖该属性的组件重渲染:

xml 复制代码
<template>
  <!-- 只有streamingContent变化时才触发重渲染 -->
  <div class="message">
    <StreamRenderer :content="message.streamingContent ?? message.content" />
  </div>
</template>

<script setup lang="ts">
import { storeToRefs } from 'pinia';

const chatStore = useChatStore();
// storeToRefs确保解构后的数据保持响应式
const { activeMessages } = storeToRefs(chatStore);

// 不需要像Zustand那样手动写selector
// Vue的模板编译器自动追踪了message.streamingContent的依赖
</script>

三、虚拟滚动集成

React + react-virtuoso

ini 复制代码
import { Virtuoso } from 'react-virtuoso';

function MessageList() {
  const messages = useActiveMessages();

  return (
    <Virtuoso
      data={messages}
      itemContent={(index, message) => (
        <MessageItem key={message.id} message={message} />
      )}
      followOutput="smooth"  // 新消息自动滚到底部
      overscan={200}          // 额外渲染200px的内容
    />
  );
}

Vue + vue-virtual-scroller

ini 复制代码
<template>
  <RecycleScroller
    :items="messages"
    :item-size="null"       // null表示动态高度
    :buffer="200"
    key-field="id"
  >
    <template #default="{ item }">
      <MessageItem :message="item" />
    </template>
  </RecycleScroller>
</template>

动态高度的坑:AI消息高度差异极大,虚拟滚动组件需要先渲染才能知道实际高度。解决方案:

  1. 初始使用预估高度,渲染后修正
  2. 使用ResizeObserver监听消息高度变化
  3. 缓存已渲染消息的高度

四、会话持久化策略

kotlin 复制代码
class SessionPersistenceManager {
  private storage: Storage;

  constructor(storage: Storage = localStorage) {
    this.storage = storage;
  }

  /** 保存会话 */
  save(session: ChatSession): void {
    const key = `chat-session-${session.id}`;

    // 策略:大消息内容压缩存储
    const compressed = this.compressSession(session);

    this.storage.setItem(key, JSON.stringify(compressed));
  }

  /** 加载会话 */
  load(sessionId: string): ChatSession | null {
    const key = `chat-session-${sessionId}`;
    const data = this.storage.getItem(key);
    if (!data) return null;

    return this.decompressSession(JSON.parse(data));
  }

  /** 获取所有会话ID */
  getAllSessionIds(): string[] {
    const ids: string[] = [];
    for (let i = 0; i < this.storage.length; i++) {
      const key = this.storage.key(i);
      if (key?.startsWith('chat-session-')) {
        ids.push(key.replace('chat-session-', ''));
      }
    }
    return ids;
  }

  /** 压缩:对长消息只保留摘要 */
  private compressSession(session: ChatSession): any {
    return {
      ...session,
      messages: session.messages.map(msg => {
        if (msg.content.length > 2000) {
          return {
            ...msg,
            content: msg.content.slice(0, 2000),
            isCompressed: true,
            originalLength: msg.content.length,
          };
        }
        return msg;
      }),
    };
  }

  private decompressSession(data: any): ChatSession {
    // 压缩过的消息需要从服务端重新加载完整内容
    return data;
  }
}

五、流式状态机实现

css 复制代码
import { createMachine, interpret, assign } from 'xstate';

const chatMachine = createMachine({
  id: 'chat',
  initial: 'idle',
  context: {
    currentMessageId: null as string | null,
    error: null as Error | null,
  },
  states: {
    idle: {
      on: {
        SEND_MESSAGE: {
          target: 'waiting',
          actions: assign({
            currentMessageId: (_: any, event: any) => event.messageId,
          }),
        },
      },
    },
    waiting: {
      on: {
        STREAM_START: 'streaming',
        ERROR: 'error',
      },
    },
    streaming: {
      on: {
        STREAM_END: 'idle',
        PAUSE: 'paused',
        STOP: 'stopping',
        ERROR: 'error',
      },
    },
    paused: {
      on: {
        RESUME: 'streaming',
        STOP: 'idle',
      },
    },
    stopping: {
      on: {
        STREAM_END: 'idle',
      },
      after: {
        5000: 'idle', // 5秒超时强制回到idle
      },
    },
    error: {
      on: {
        RETRY: 'waiting',
        DISMISS: 'idle',
      },
    },
  },
});

为什么用状态机? 流式输出的状态流转有严格的时序约束(不能从idle直接到streaming,不能从streaming直接到waiting)。状态机让非法转换不可能发生,调试时也能清晰看到状态轨迹。

实践任务

任务:任选React或Vue,实现完整的AI对话状态管理+虚拟滚动列表,支持多会话切换与本地持久化。

验收标准

  1. 发送消息 → 流式输出 → 完成,状态流转正确
  2. 流式输出期间,只有当前消息组件重渲染
  3. 虚拟滚动:100+条消息不卡顿
  4. 多会话切换:输入框内容、滚动位置正确恢复
  5. 本地持久化:刷新页面后会话不丢失

面试题解析

Q:AI对话的状态管理与传统聊天的状态管理有什么区别?

答题要点

  1. 流式中间态:AI消息有streaming状态,内容实时追加,需要分离content与streamingContent避免全量重渲染
  2. Token预算:上下文窗口有限,需要裁剪策略(滑动窗口/摘要压缩)
  3. DOM性能:Markdown渲染的DOM节点数远超纯文本,必须虚拟滚动
  4. 异步生命周期:生成过程可暂停/停止/重新生成,状态流转需要严格管理
  5. 持久化复杂度:流式内容不需要持久化,已完成消息需要,需要区分处理

👤 关于作者

JavaAgent架构师 --- 十年Java分布式架构老兵,专注AI Agent企业级落地。

主导过数字员工、SOP智能引擎等项目,开发过RPC框架、消息中间件、ORM框架。

正在输出:

专栏一:《前端AI工程化》10期适合前端 SSE/流式渲染/.../企业级AI架构/AI前端面试深度解析

专栏二:《Java体系也能玩转AI》24期加急中,适合java深度玩家

专栏三:《从0构建Agent系统》 15期加急中,适合所以玩家

让Java开发者不转Python也能构建企业级AI应用


点赞+关注+评论 走一波。

-------------------------淘汰自己不是别人,是自己!!!------------------------------------

下期预告:前端AI工程化(六):Function Calling与RAG前端实践,我们将从状态管理进入AI能力编排层,拆解前端如何驱动LLM的工具调用与知识检索增强。

相关推荐
aneasystone本尊1 小时前
给小龙虾上把锁:Sandbox 沙箱机制
人工智能
Σίσυφος19001 小时前
数据标准化(拟合的时候使用非常重要)
人工智能·算法
ricardo19731 小时前
一张图搞懂 HTTP 缓存:强缓存、协商缓存与最佳 Cache-Control 配置
前端
程序员码歌1 小时前
别再让 AI 自由发挥了:OpenSpec 才是团队协作不跑偏的关键
android·前端·人工智能
时光Autistic2 小时前
【安装教程】AI标注工具X-AnyLabeling安装配置
人工智能·python
用户11481867894842 小时前
Vue 开发者快速上手 Flutter(二)
前端
knight_9___2 小时前
大模型project面试7
人工智能·python·算法·面试·大模型·agent
liudanzhengxi2 小时前
CRM系统技术文章
linux·服务器·网络·人工智能·新人首发
用户11481867894842 小时前
Vue 开发者快速上手 Flutter(三)
前端