LangChain.js Agent Memory 实战(下):用 Milvus 构建可检索的长期记忆

LangChain.js Agent Memory 实战(下):用 Milvus 构建可检索的长期记忆

摘要:围绕检索式 Memory,完整实现对话向量化、Milvus 存储、相似度搜索、上下文注入与新记忆回写,并说明短期历史、摘要和长期语义记忆如何协同。

上篇解决了三个问题:

  • 用消息历史让模型拥有连续对话能力;
  • 用文件让消息跨进程持久化;
  • 用截断和摘要控制上下文长度。

但截断与摘要都围绕"最近发生了什么"管理上下文。假设用户进行了大量对话,现在突然问到很久以前讨论过的向量数据库,最近几条消息里未必包含相关内容;如果只保留近期上下文,那段旧信息已经不可见;如果每次传入完整历史,上下文又会持续膨胀。

检索式 Memory 提供了第三条路线:

text 复制代码
历史对话持久化到 Milvus
          ↓
当前问题转换成向量
          ↓
按向量相似度检索相关历史
          ↓
相关历史与当前问题一起交给模型
          ↓
把本轮问答继续写回 Milvus

这样,长期记忆不必全部进入当前上下文。每一轮只取与问题语义最接近的少量记录,既控制输入规模,又能找回较早的信息。

本文将完成这条闭环:创建 Milvus 集合、生成 Embedding、插入历史对话、执行相似度检索、拼接模型上下文,并把新问答保存为下一轮可检索的记忆。

一、检索式 Memory 的组成

整个过程包含三个核心对象:

  1. Embedding 模型:把自然语言转换成固定维度的向量;
  2. Milvus:保存向量及其对应的对话正文、轮次和时间;
  3. 聊天模型:读取检索到的相关历史并回答当前问题。

依赖如下:

json 复制代码
{
  "dependencies": {
    "@langchain/core": "^1.2.11",
    "@langchain/openai": "^1.5.13",
    "@zilliz/milvus2-sdk-node": "^3.0.5",
    "dotenv": "^17.4.2"
  }
}

环境变量需要同时描述聊天模型、Embedding 模型和 Milvus 地址:

dotenv 复制代码
MODEL_NAME=你的聊天模型名称
OPENAI_API_KEY=你的API-Key
OPENAI_BASE_URL=你的模型服务地址
EMBEDDINGS_MODEL_NAME=你的Embedding模型名称
MILVUS_ADDRESS=你的Milvus地址

这里让聊天模型与 Embedding 模型使用相同的 API Key 和基础地址,但模型名称分别配置。

二、初始化 Embedding 模型和 Milvus 客户端

先定义集合名称与向量维度:

js 复制代码
const COLLECTION_NAME = "conversations";
const VECTOR_DIM = 1024;

再创建 Embedding 实例:

js 复制代码
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  dimensions: VECTOR_DIM,
});

async function getEmbedding(text) {
  return embeddings.embedQuery(text);
}

embedQuery(text) 接收一段文本,返回长度为 1024 的数字数组。这个数组本身不是给读者阅读的内容,而是后续相似度搜索的依据。

VECTOR_DIM 会同时用于 Embedding 配置和 Milvus 字段定义,两处必须一致:

text 复制代码
Embedding 输出维度:1024
Milvus vector 字段维度:1024

然后创建 Milvus 客户端:

js 复制代码
import { MilvusClient } from "@zilliz/milvus2-sdk-node";

const client = new MilvusClient({
  address: process.env.MILVUS_ADDRESS,
});

正式执行建表、写入或检索前,等待客户端连接:

js 复制代码
console.log("连接到 Milvus...");
await client.connectPromise;
console.log("连接成功");

三、为长期对话设计集合结构

关系型数据库通常把结构化字段组织成表,而这里把长期对话保存在名为 conversations 的集合中。每条记录包含五个字段:

字段 Milvus 类型 用途
id VarChar 唯一标识一条对话记录,同时作为主键
vector FloatVector 对话正文对应的 1024 维向量
content VarChar 可读的用户或 AI 对话正文
round Int64 对话轮次
timestamp VarChar ISO 格式时间字符串

创建集合:

js 复制代码
import {
  DataType,
  IndexType,
  MetricType,
} from "@zilliz/milvus2-sdk-node";

await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    {
      name: "id",
      data_type: DataType.VarChar,
      max_length: 50,
      is_primary_key: true,
    },
    {
      name: "vector",
      data_type: DataType.FloatVector,
      dim: VECTOR_DIM,
    },
    {
      name: "content",
      data_type: DataType.VarChar,
      max_length: 5000,
    },
    {
      name: "round",
      data_type: DataType.Int64,
    },
    {
      name: "timestamp",
      data_type: DataType.VarChar,
      max_length: 100,
    },
  ],
});

时间字段使用 new Date().toISOString() 生成字符串:

js 复制代码
timestamp: new Date().toISOString()

因此 schema 中把它定义为 VarChar,而不是日期类型。

这个 schema 还体现了向量数据库记录的两部分:

  • vector 用于机器计算相似度;
  • contentroundtimestamp 用于把检索结果重新组织成人可以理解、模型也可以读取的上下文。

只保存向量而不保存正文,检索后就无法把真实对话交还给聊天模型。

四、为向量字段创建索引

集合创建后,为 vector 字段创建索引:

js 复制代码
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: "vector",
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
});

console.log("索引已经创建");

这段配置表达了三件事:

  • 检索目标是 vector 字段;
  • 索引类型使用 IVF_FLAT
  • 相似度度量使用 COSINE

后续搜索也会明确写出 MetricType.COSINE,保证写入后的索引配置和查询时的度量方式保持一致。

集合和索引属于初始化工作。下面的初始化程序应在首次准备数据时执行;如果同名集合已经存在,再次创建会遇到名称冲突,因此不要把初始化逻辑混在每一轮聊天请求里反复执行。

五、把种子对话转换成向量并写入 Milvus

先准备三轮、六条对话:

js 复制代码
const conversations = [
  {
    id: "conversation_1",
    content: "用户:你好,我最近正在学习 Milvus 向量数据库。",
    round: 1,
    timestamp: new Date().toISOString(),
  },
  {
    id: "conversation_2",
    content: "AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。",
    round: 1,
    timestamp: new Date().toISOString(),
  },
  {
    id: "conversation_3",
    content: "用户:向量数据库和 MySQL 有什么区别?",
    round: 2,
    timestamp: new Date().toISOString(),
  },
  {
    id: "conversation_4",
    content: "AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。",
    round: 2,
    timestamp: new Date().toISOString(),
  },
  {
    id: "conversation_5",
    content: "用户:那 RAG 为什么要使用向量数据库?",
    round: 3,
    timestamp: new Date().toISOString(),
  },
  {
    id: "conversation_6",
    content: "AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。",
    round: 3,
    timestamp: new Date().toISOString(),
  },
];

每条记录此时还缺少 vector。可以用 Promise.all() 并发生成所有向量,再保留原字段:

js 复制代码
const conversationData = await Promise.all(
  conversations.map(async (item) => ({
    ...item,
    vector: await getEmbedding(item.content),
  }))
);

转换前:

js 复制代码
{
  id,
  content,
  round,
  timestamp
}

转换后:

js 复制代码
{
  id,
  content,
  round,
  timestamp,
  vector
}

最后一次性写入:

js 复制代码
await client.insert({
  collection_name: COLLECTION_NAME,
  data: conversationData,
});

这里按单条用户消息或 AI 消息存储种子数据,所以一次检索可能返回某个问题,也可能返回与它对应的回答。后面保存新对话时会采用稍有不同的粒度:把一整轮"用户问题 + AI 回答"合并成一条记录。

六、完整的初始化程序

把连接、建集合、建索引和插入种子数据组合起来:

js 复制代码
import "dotenv/config";
import {
  MilvusClient,
  DataType,
  MetricType,
  IndexType,
} from "@zilliz/milvus2-sdk-node";
import { OpenAIEmbeddings } from "@langchain/openai";

const COLLECTION_NAME = "conversations";
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  dimensions: VECTOR_DIM,
});

async function getEmbedding(text) {
  return embeddings.embedQuery(text);
}

const client = new MilvusClient({
  address: process.env.MILVUS_ADDRESS,
});

async function main() {
  try {
    console.log("连接到 Milvus...");
    await client.connectPromise;
    console.log("连接成功");

    await client.createCollection({
      collection_name: COLLECTION_NAME,
      fields: [
        {
          name: "id",
          data_type: DataType.VarChar,
          max_length: 50,
          is_primary_key: true,
        },
        {
          name: "vector",
          data_type: DataType.FloatVector,
          dim: VECTOR_DIM,
        },
        {
          name: "content",
          data_type: DataType.VarChar,
          max_length: 5000,
        },
        {
          name: "round",
          data_type: DataType.Int64,
        },
        {
          name: "timestamp",
          data_type: DataType.VarChar,
          max_length: 100,
        },
      ],
    });

    await client.createIndex({
      collection_name: COLLECTION_NAME,
      field_name: "vector",
      index_type: IndexType.IVF_FLAT,
      metric_type: MetricType.COSINE,
    });

    const conversations = [
      {
        id: "conversation_1",
        content: "用户:你好,我最近正在学习 Milvus 向量数据库。",
        round: 1,
        timestamp: new Date().toISOString(),
      },
      {
        id: "conversation_2",
        content: "AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。",
        round: 1,
        timestamp: new Date().toISOString(),
      },
      {
        id: "conversation_3",
        content: "用户:向量数据库和 MySQL 有什么区别?",
        round: 2,
        timestamp: new Date().toISOString(),
      },
      {
        id: "conversation_4",
        content: "AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。",
        round: 2,
        timestamp: new Date().toISOString(),
      },
      {
        id: "conversation_5",
        content: "用户:那 RAG 为什么要使用向量数据库?",
        round: 3,
        timestamp: new Date().toISOString(),
      },
      {
        id: "conversation_6",
        content: "AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。",
        round: 3,
        timestamp: new Date().toISOString(),
      },
    ];

    const conversationData = await Promise.all(
      conversations.map(async (item) => ({
        ...item,
        vector: await getEmbedding(item.content),
      }))
    );

    await client.insert({
      collection_name: COLLECTION_NAME,
      data: conversationData,
    });

    console.log("初始对话已经写入 Milvus");
  } catch (error) {
    console.error("错误:", error);
  }
}

main();

初始化完成后,conversations 集合便拥有可供检索的长期对话。

七、根据当前问题检索相关历史

检索函数接收当前问题和返回数量 k

js 复制代码
import { MetricType } from "@zilliz/milvus2-sdk-node";

async function retrieveRelevantConversations(query, k = 2) {
  try {
    const queryVector = await getEmbedding(query);

    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: k,
      metric_type: MetricType.COSINE,
      output_fields: ["id", "content", "round", "timestamp"],
    });

    return searchResult.results;
  } catch (error) {
    console.error("检索对话时出错:", error.message);
    return [];
  }
}

它的执行过程可以拆成三步。

1. 把当前问题转换成向量

js 复制代码
const queryVector = await getEmbedding(query);

历史正文和当前问题使用同一个 Embedding 配置,产生相同维度的向量。

2. 在同一个向量字段上执行搜索

js 复制代码
const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  limit: 2,
  metric_type: MetricType.COSINE,
  output_fields: ["id", "content", "round", "timestamp"],
});

limit: 2 表示只返回最相关的两条记录。控制 k,也就控制了注入模型的长期历史数量。

3. 返回可构造上下文的字段

vector 已经完成了检索任务,交给聊天模型时真正需要的是可读字段,因此通过 output_fields 取回 ID、正文、轮次和时间。

发生异常时返回空数组:

js 复制代码
async function retrieveRelevantConversations(query, k = 2) {
  try {
    // 执行向量检索
  } catch (error) {
    console.error("检索对话时出错:", error.message);
    return [];
  }
}

这样上层聊天逻辑仍然可以退化为只使用当前用户问题,而不是因为没有检索结果就无法继续调用模型。

八、把检索结果注入模型上下文

假设要连续演示三个问题:

js 复制代码
const conversations = [
  { input: "为什么要学习向量数据库" },
  { input: "MySQL 和 Milvus 的区别" },
  { input: "RAG 和哪个数据库强相关" },
];

每一轮先执行检索:

js 复制代码
const retrievedConversations = await retrieveRelevantConversations(input, 2);

再把结果转换成一段结构化文本:

js 复制代码
let relevantHistory = "";

if (retrievedConversations.length > 0) {
  relevantHistory = retrievedConversations
    .map((item, index) => `
[历史对话 ${index + 1}]
轮次:${item.round}
${item.content}`)
    .join("\n\n----------\n\n");
} else {
  console.log("未找到相关历史对话");
}

如果成功找到历史,最终消息会同时包含检索内容和当前问题:

js 复制代码
const contextMessages = relevantHistory
  ? [
      new HumanMessage(`相关历史对话:
${relevantHistory}

用户问题:${input}`),
    ]
  : [userMessage];

const response = await model.invoke(contextMessages);

模型实际接收到的内容大致如下:

text 复制代码
相关历史对话:

[历史对话 1]
轮次:2
用户:向量数据库和 MySQL 有什么区别?

----------

[历史对话 2]
轮次:2
AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。

用户问题:MySQL 和 Milvus 的区别

这里并不是要求聊天模型自己访问 Milvus。检索发生在调用模型之前,应用把结果整理成 HumanMessage,模型只负责阅读这段上下文并生成回答。

如果没有结果,contextMessages 就退化为原始的 userMessage

js 复制代码
[new HumanMessage(input)]

九、短期 history 与模型上下文不是同一个概念

演示中还维护了一个内存历史:

js 复制代码
const history = new InMemoryChatMessageHistory();

await history.addMessage(userMessage);
await history.addMessage(response);

但调用模型使用的是:

js 复制代码
await model.invoke(contextMessages);

而不是:

js 复制代码
await model.invoke(await history.getMessages());

这意味着当前实现里,history 负责记录本次程序运行期间发生的问答,但不会自动参与下一轮模型输入。真正进入模型上下文的是"Milvus 检索结果 + 当前问题"。

这一点很重要:

text 复制代码
addMessage() 只是保存消息
getMessages() 并放入 model.invoke() 才会影响模型本轮回答

因此,这个示例突出的是检索式长期记忆 ,而不是把本次运行的全部短期消息也带入每一轮。如果需要同时使用两者,就要在构造 contextMessages 时明确合并,而不是只调用 history.addMessage()

十、把新问答写回长期记忆

模型回答后,先把本轮用户问题和 AI 回答组合成一段文本:

js 复制代码
const conversationText = `用户:${input}
AI:${response.content}`;

再生成唯一 ID、向量与时间:

js 复制代码
const conversationId = `conv_${Date.now()}_${index + 1}`;
const conversationVector = await getEmbedding(conversationText);

最后写回同一个集合:

js 复制代码
await client.insert({
  collection_name: COLLECTION_NAME,
  data: [
    {
      id: conversationId,
      content: conversationText,
      vector: conversationVector,
      round: index + 1,
      timestamp: new Date().toISOString(),
    },
  ],
});

至此,一轮对话形成了闭环:

text 复制代码
当前问题
   ↓ Embedding
查询 Milvus
   ↓
相关历史 + 当前问题
   ↓
聊天模型回答
   ↓
用户问题 + AI 回答
   ↓ Embedding
写回 Milvus

下一轮检索不仅能找到最初插入的种子对话,也可能找到刚刚生成的新对话。

种子数据按单条消息存储,新数据则把一问一答合并成一条记录。这两种粒度都能被当前 schema 接收,不过理解检索结果时要注意区别:前者的一条结果只代表用户或 AI 的单条消息,后者的一条结果代表完整一轮问答。

十一、完整的检索式 Memory 示例

下面把检索、生成和写回组合成一个可以直接理解的完整流程:

js 复制代码
import "dotenv/config";
import { OpenAIEmbeddings, ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage } from "@langchain/core/messages";
import {
  MilvusClient,
  MetricType,
} from "@zilliz/milvus2-sdk-node";

const COLLECTION_NAME = "conversations";
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  dimensions: VECTOR_DIM,
});

async function getEmbedding(text) {
  return embeddings.embedQuery(text);
}

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
});

const client = new MilvusClient({
  address: process.env.MILVUS_ADDRESS,
});

async function retrieveRelevantConversations(query, k = 2) {
  try {
    const queryVector = await getEmbedding(query);
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: k,
      metric_type: MetricType.COSINE,
      output_fields: ["id", "content", "round", "timestamp"],
    });

    return searchResult.results;
  } catch (error) {
    console.error("检索对话时出错:", error.message);
    return [];
  }
}

async function retrievalMemoryDemo() {
  try {
    console.log("连接到 Milvus...");
    await client.connectPromise;
    console.log("连接成功\n");
  } catch (error) {
    console.error("无法连接到 Milvus:", error.message);
    return;
  }

  const history = new InMemoryChatMessageHistory();
  const conversations = [
    { input: "为什么要学习向量数据库" },
    { input: "MySQL 和 Milvus 的区别" },
    { input: "RAG 和哪个数据库强相关" },
  ];

  for (let index = 0; index < conversations.length; index++) {
    const { input } = conversations[index];
    const userMessage = new HumanMessage(input);

    console.log(`第 ${index + 1} 轮对话,用户:${input}`);
    console.log("[检索相关历史对话]");

    const retrievedConversations =
      await retrieveRelevantConversations(input, 2);

    let relevantHistory = "";

    if (retrievedConversations.length > 0) {
      relevantHistory = retrievedConversations
        .map((item, resultIndex) => `
[历史对话 ${resultIndex + 1}]
轮次:${item.round}
${item.content}`)
        .join("\n\n----------\n\n");
    } else {
      console.log("未找到相关历史对话");
    }

    const contextMessages = relevantHistory
      ? [
          new HumanMessage(`相关历史对话:
${relevantHistory}

用户问题:${input}`),
        ]
      : [userMessage];

    const response = await model.invoke(contextMessages);
    console.log(response.content);

    await history.addMessage(userMessage);
    await history.addMessage(response);

    const conversationText = `用户:${input}
AI:${response.content}`;
    const conversationId = `conv_${Date.now()}_${index + 1}`;
    const conversationVector = await getEmbedding(conversationText);

    try {
      await client.insert({
        collection_name: COLLECTION_NAME,
        data: [
          {
            id: conversationId,
            content: conversationText,
            vector: conversationVector,
            round: index + 1,
            timestamp: new Date().toISOString(),
          },
        ],
      });
    } catch (error) {
      console.error("插入失败:", error);
    }
  }
}

retrievalMemoryDemo().catch(console.error);

运行顺序是:先单独执行初始化,确保集合、索引和种子对话已经存在;然后再执行检索式聊天流程。

十二、三种上下文管理方式放在一起比较

到这里,已经得到三种不同的 Memory 管理策略:

策略 选择历史的依据 进入上下文的内容 主要特点
截断 时间顺序 最近若干消息 简单直接,旧信息被移除
总结 时间顺序 + 模型压缩 旧历史摘要 + 最近原文 能保留旧信息要点,但细节被压缩
检索 当前问题与历史的向量相似度 最相关的 K 条历史 能找回较早且相关的对话

它们回答的是三个不同问题:

text 复制代码
截断:最近说了什么?
总结:过去总体说了什么?
检索:过去哪些内容与当前问题最相关?

因此,完整的 Memory 模块可以同时拥有这些能力,而不是只能三选一。例如:

text 复制代码
当前会话保留最近消息
        +
较早会话定期生成摘要
        +
长期内容写入 Milvus
        +
每轮按问题检索相关历史

一个自然的演进方向是:例如每积累 20 条对话就触发一次总结,把摘要或对话写入 Milvus;后续再从 Milvus 中取回相关历史,与近期消息共同构成上下文。这样 Memory 不只是一个不断增长的 messages 数组,而会成为 Agent Harness 中独立的存储与管理模块。

十三、实现时最容易混淆的几个点

1. 保存过,不等于模型看见了

无论消息保存在内存、文件还是 Milvus 中,都必须在调用前读取并放进 model.invoke() 的消息数组,才会影响当前回答。

2. Embedding 维度必须前后一致

示例明确使用 1024:

js 复制代码
dimensions: 1024

集合字段也必须是:

js 复制代码
dim: 1024

否则生成的向量不能按当前 schema 正常写入或搜索。

3. 建索引和搜索使用同一种度量

创建索引和搜索都使用 MetricType.COSINE。不要只改其中一处而让两边的配置表达不同的相似度标准。

4. content 才是最终注入聊天模型的内容

向量用于定位记录,聊天模型读取的是 content。所以记录中既要有机器检索用的 vector,也要有模型可读的正文。

5. 初始化和聊天循环职责不同

集合、字段和索引负责准备存储结构;聊天循环负责检索与追加数据。将两者分开,可以避免每次聊天都重复创建同名集合。

总结

从内存消息数组一路走到 Milvus,可以看到 Agent Memory 的重点从来不只是"把聊天记录存下来",而是同时解决两个问题:

  1. 存储问题:历史放在内存、文件还是数据库;
  2. 管理问题:当前这一轮究竟选择哪些历史交给模型。

短期对话可以使用 InMemoryChatMessageHistory,跨进程恢复可以使用文件历史;上下文变长后,可以按消息数或 Token 数截断,也可以把旧对话总结成摘要;当长期历史更多时,则可以用 Embedding 与 Milvus 按语义取回相关记录。

最终形成的检索式 Memory 闭环是:

text 复制代码
对话产生 → 向量化 → 持久化
   ↑                     ↓
模型回答 ← 注入上下文 ← 相似度检索

到这一步,模型依然是无状态的,但 Agent 已经能够通过 Harness 中的 Memory 模块保存历史、控制上下文,并在需要时找回相关信息。

相关推荐
半个落月1 小时前
LangChain.js Agent Memory 实战(上):从内存对话、文件持久化到截断与摘要
javascript·langchain
linux_cfan3 小时前
17 · 引擎适配器全景:HLS/DASH/Vimeo/Mux/Cast
前端·javascript·音视频
梦在远山后5 小时前
Electron 与 FastAPI 如何完成流式 Agent 对话
python·langchain·agent
何何____5 小时前
Vue 生命周期详解
前端·javascript
默_笙7 小时前
💫 闭包是个背包:拆解小米前端面试题里的三道"闭包陷阱"
前端·javascript·面试
猿人谷8 小时前
Jev:当 AI 不再生成 Token,而是直接做决策
后端·langchain·aigc
逆风飞翔的小叔8 小时前
【AI智能体】Langchain 主流大模型调用与对话API使用详解
langchain·langchain 对话api·langchain 对话·langchain 对话使用·langchain 对话详解·langchain api使用
尾善爱看海9 小时前
《JavaScript 数组操作全攻略:12 类 API + 30 个实战场景 + 20 个避坑指南》
前端·javascript·面试
OpsEye10 小时前
为什么你的 Agent 莫名烧钱?聊聊循环调用的兜底方案
javascript·ai编程