切图仔啊~来搭Agent吧

106 阅读3分钟

本文旨在分享个人使用第三方接口搭建 Agent 过程中的一些经验

需要参考具体项目代码可以参考这里

🛡️叠甲:本文中的设计仅为个人尝试与记录,旨在分享思路,非最佳或唯一解


架构设计

实现一个 Agent 的方案设计有很多,通常可以分为以下的架构方案:

串行处理

流程:用户输入 → 入口模型进行意图识别与预处理 → 按识别结果分发到相应任务模块(如搜索、执行、API 调用等)→ 输出结果

特点:执行路径固定,处理逻辑清晰,任务相对独立

并行处理

流程:用户输入 → 多个模型/工具并行处理任务的不同部分 → 结果聚合与融合 → 输出最终结果

特点:多任务同时执行,提升响应速度与多源信息处理能力

主控调度

流程:用户输入 → 主Agent进行任务拆解与分配 → 多个模型/工具协作处理 → 结果汇总至主Agent → 主Agent整合与生成最终结论

特点:主Agent作为调度中心,具备规划、协调与验证能力,支持动态任务规划与多轮反思

image.png

目前主控调度是主流的设计方案,所以后续文章内容以介绍该架构为主


模型选择

由上面架构设计可以知道,主 Agent 主要负责理解用户需求、规划任务、调用工具、总结结果,那么可以很自然地得出选取一个模型成为主 Agent 的条件:

  • 通常选用大语言模型(LLM)
  • 具备强推理能力,即有深度思考功能
  • 具备调用工具的能力,即支持 Function Call 和 MCP
  • 保持任务状态和全局理解,即上下文容量充足

一般在模型提供方都回标注模型能力,以豆包为例,其他的功能可有可无,因为我们后续都可以跟搭积木一样通过工具给 Agent 补充各个方面的能力~

image.png


接口调用

首先,在使用接口之前,需要强调一个概念:模型不具备记忆

调用一个大模型接口,它的所有信息都只来自于当前请求中的内容,不会在对话结束后保存任何新的状态

例如,当第一次调用模型接口时,输入输出可能是这样

axios.post(
  apiUrl,
  {
    model: "gpt-4o-mini",
    messages: [{ role: "user", content: "我的名字是小李。" }]
  },
  {
    headers: {
      "Authorization": `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
  }
)

    // 接口回复 "你好,小李!"

那么,在后续的调用中,如果你想保证上下文完整,你必须得带上之前所有的上下文

axios.post(
  // ...
  {
    model: "gpt-4o-mini",
    messages: [
      { role: "user", content: "我的名字是小李。" },
      { role: "assistant", content: "你好,小李!" },
      { role: "user", content: "我叫什么名字?" },		// 最新的回复输入
    ]
  },

)

这里仅做概念理解,在真实的设计中不会真的这样做来让 Agent 理解上下文,后面会介绍 ~


工具

工具是给 Agent 扩充功能的一种手段,以最简普通的联网搜索功能为例,一般 LLM 接口是不提供联网功能的,此时如果需要增加这个功能,则需要引入 Function Call 或 MCP(Model Context Protocol) 功能,下面简单介绍下这两个:

  • Function Call:模型的内部能力 —— 模型自行判断是否调用、调用哪个函数、以及传递什么参数
  • MCP:模型外部的通信协议 —— 规定如何连接、发现和交互外部工具

当第三方平台接口支持 MCP 能力时,意味着该接口已经内置 MCP 桥接层(bridge/client),能直接理解并处理 MCP 协议格式的消息,因此使用时只需要:

  1. 配置 MCP 服务端:用于暴露工具或数据接口
  2. 启动 MCP 客户端:连接到该 MCP 服务

Function Call

首先来看下,如果用调用接口的方式,使用 Function Call 扩充联网功能,一般是怎么做的,整体流程如下:

  1. 定义工具函数:实现真实的联网搜索逻辑,并在接口中注册函数名称、描述和参数结构
  2. 编写提示词:告诉模型在什么条件下,需要调用联网工具
  3. 发送第一轮请求:请求体中包含系统提示词,用户输入、工具列表
  4. 判断调用工具:如果模型调用了工具,此轮模型的回复内容会为空,同时在 tools_calls 字段中说明调用工具 id,名字,模型预期函数传参等
  5. 手动执行工具函数:根据 tools_calls 信息,手动调用同名函数,传入预期传参并拿到返回结果
  6. 发送第二轮请求:在第二轮请求中,在第一轮请求体的基础上,加上本次工具函数的执行结果说明
  7. 循环执行:如果此时返回依然触发了工具调用,则重复 3~6 步骤,直到模型决定不再调用工具,输出最终结果
// 定义工具函数 遵循 JSON Schema 规范定义即可
const webSearchTool = {
  type: "function",
  function: {
    name: "search_web",
    description: "联网搜索工具,当用户询问实时内容,或者问题内容你不确定真实性时调用",
    parameters: {
      type: "object",
      properties: {
        query: { type: "string", description: "搜索关键词" },
      },
      required: ["query"]
    }
  }
}

// 真实调用函数
const async serach_web = (args: { query: string }) => { 
  // ....具体做法省略 
}

// 定义系统提示词
const systemPrompt = `
  你是一个智能助手。当用户提问时:
    - 如果问题与实时信息或互联网内容相关,请调用函数 search_web
    - 如果问题可以直接回答,则直接回复
`

// 第一轮请求
const firstReply = await axios.put(url, {
  // ...
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: systemPrompt },
    { role: "user", content: "帮我查一下今天国内新闻" },  
  ],
  tools: [webSearchTool]
})

// 判断工具调用
const message = firstReply.data.choices[0].message

if (message.tool_calls && message.tool_calls.length > 0) {
  const call = message.tool_calls[0]
  const { id, name, arguments } = call

  // 执行对应工具函数
  let toolResult
  if(name === "search_web") {
    toolResult = await search_web(JSON.parse(arguments))
  }

  // 开启第二轮请求
  const secondReply = await axios.put(url, {
    // ...
    model: "gpt-4o-mini",
    messages: [
      { role: "system", content: systemPrompt },
      { role: "user", content: "帮我查一下今天国内新闻" },
      { role: "assistant", tool_calls: message.tool_calls },
      { role: "tool", tool_call_id: id, content: toolResult }
    ],
    tools: [webSearchTool]
  })

  // ... 后续接力
}

MCP

MCP 的核心在于告诉 Agent 它有什么能力,让 Agent 自主调用,而不是像 Function Call 那样需要开发者自己来调用工具函数,它有多种传输方式:WebSocket 、Http + Sse 、StreamableHttp 、Stdio 等

有些框架(如 Prisma)会提供自己的 MCP 服务,可以直接通过客户端连接,也可以在本地编写 Node 服务,用 Stdio 接入使用,本文主要介绍这种方式

自己搭建 MCP 的服务端和客户端可以使用 @modelcontextprotocol/inspector@modelcontextprotocol/sdk 来做

首先需要搭建一个 MCP 服务端

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"

const server = new McpServer({ name: "web-search-mcp", version: "1.0.0" })

// 注册工具
server.registerTool(
  'web.search',
  {
    description: '根据搜索词搜索网页内容',
    inputSchema: { query: { type: "string", description: "搜索关键词" } }
  },
  async (input) => {
    // ...
  }
)

const transport = new StdioServerTransport()
await server.connect(transport)
console.log('[MCP] web.search 服务启动成功')

接着构建客户端,连接服务端

import { McpClient } from "@modelcontextprotocol/sdk/client/mcp.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"

// 连接 MCP 服务
const createMCPClient = async () => {
  const child = spawn("node", ["./mcp-server.js"], {
    stdio: ["pipe", "pipe", "inherit"],
  })
  const transport = new StdioClientTransport({
    stdin: child.stdin,
    stdout: child.stdout,
  });
  const client = new McpClient(transport)
  await client.connect()
  return client
}

const mcpClient = await createMCPClient()

// 调用接口
const systemPrompt = `
  你是一个智能助手,你具有以下能力
    - 联网查询
      - 关联 MCP: web.search
      - 触发时机: 当问题与实时信息或互联网内容相关时
`

// 第一轮请求
const firstReply = await axios.put(url, {
  // ...
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: systemPrompt },
    { role: "user", content: "帮我查一下今天国内新闻" },  
  ]
  // 可不传tools,桥接到服务后,支持 MCP 的模型会自动发现注册的工具并调用
})

const message = resp1.data.choices[0].message

if (message.tool_calls?.length) {
  for (const call of message.tool_calls) {
    const args = JSON.parse(call.arguments || "{}")
    const result = await mcpClient.callTool(call.name, args)

    // 把工具结果返回模型继续生成
    const secondReply = await axios.post(url, {
      model: "gpt-4o-mini",
      messages: [
        { role: "system", content: stystemPrompt },
        { role: "user", content: "帮我查一下今天国内新闻" },
        { role: "assistant", tool_calls: message.tool_calls },
        { role: "tool", tool_call_id: call.id, content: JSON.stringify(result) },
      ],
    })

    // ...
  }
}

可以看出,无论是使用 Fcuntion Call 还是 MCP,多次的工具调度处理会很麻烦,后续会引入框架来解决这个问题~


提示词

在 Agent 的整体流程中,提示词(Prompt),它决定了 Agent 如何理解任务、规划步骤并执行行动,然而编写提示词也往往是最痛苦的一环,常见原因如下:

  • 表达自由度极高:提示词没有固定的格式或语法规则,设计方式高度自由,往往需要反复试验
  • 依赖经验和直觉:一般以"我寻思模型能理解"的假设设计提示词,而非确定性的逻辑,导致效果无法完全可控
  • 模型理解差异:各平台的模型解析提示词的能力与偏好不同,同一份提示词内容可能需要针对不同平台模型进行微调适配

分类

在开发过程中可控的提示词分为三种:

  • 系统提示词:一般用于确定模型的基础人格、语气、行为边界以及总体任务目标
  • 用户提示词:一般用于确定用户发出的实时输入,包括问题、命令、或具体任务请求
  • 工具提示词:定义工具的输入输出规则与上下文信息

系统/用户提示词

先来说说系统/用户提示词,举个简单的场景示例,如果有一个聊天模型,以及一份用户样本数据,那么提示词可以是这样划分的

特殊标志 {data} 常代表插值语法,最终生成 Prompt 时需要从外部补充具体数据进去

【系统提示词】
# 人设
你是一个可靠、共情且专业的中文对话助手:
- 语气轻松但尊重;用短句和口语化表达,避免过度专业
- 避免挖掘隐私;对敏感话题先征询意愿再继续

# 限制
- 不编造来源;不输出敏感/违法内容。 
- 对话控制在500字以内

【用户提示词】
# 用户输入  
输入: {user_input}

# 用户特征  
- 背景:{user_background}  
- 偏好:{user_preferences}  
- 禁忌:{user_constraints}  

# 应用用户特征指南 
- 优先级:禁忌 > 偏好 > 背景
- 对于可能触发禁忌性内容的回答,先征询意愿再深入
- 在理解背景基础上,以偏好调整"表达方式"(语气、长度、是否举例/表情),不改变事实

可以看到,虽然上面写的看起来还算规整,但是在真实开发中,系统/用户提示词的边界往往很模糊,即使上面把用户提示词的内容全都搬到系统提示词里去,模型的输出往往不会有差异影响,区分系统/用户提示词更像是一种内容管理策略,而不是模型性能要求

所以在实践中,如果你还没有想好该怎么管理提示词内容,纠结哪部分提示词该写在系统,哪部分该写进用户,那么我建议你直接 ALL IN 系统提示词即可~

JSON Schema与工具提示词

接下来是我想重点聊聊的部分内容

工具提示词本质上就是强要求 Agent 理解输入/输出数据结构的一个过程,实际上,不仅工具提示词,在任意提示词中,只要一个 Prompt 涉及让模型深刻理解并遵守某个数据的结构。那么一定离不开一个概念 ——JSON Schema,这是因为 JSON Schema 承担了"结构契约"的角色,它不仅告诉模型数据长什么样,还定义允许范围、类型约束以及关系逻辑

在上面的 工具 一节中,我已展示过通过 JSON Schema 定义工具传参类型的写法,但是在写提示词时,我们不可能真的使用这种繁琐的写法,因此,需要引入 zod 和 zod-to-json-schema 库,前者可以很方便的书写一个 Schema,后者则是将定义好的 Schema 转成字符串回填到我们的提示词中

下面是一个直接书写和使用库后的开发体验对比示例:

// 原始书写
const tools = [
  {
    type: "function",
    function: {
      name: "getWeather",
      description: "获取指定城市的天气信息",
      parameters: {
        type: "object",
        properties: {
          city: {
            type: "string",
            description: "城市名称,如 Beijing",
          },
          unit: {
            type: "string",
            enum: ["celsius", "fahrenheit"],
            description: "温度单位",
          },
        },
        required: ["city"],
      },
    },
  },
]

// 使用库
import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"


const weatherSchema = z.object({
  city: z.string().describe("城市名称,如 Beijing"),
  unit: z.enum(["celsius", "fahrenheit"]).optional().describe("温度单位"),
})

const tools = [
  {
    type: "function",
    function: {
      name: "getWeather",
      description: "获取指定城市的天气信息",
      parameters: zodToJsonSchema(weatherSchema),
    },
  },
]

同时,在 ts 项目中,zod 在定义一套 schema 的同时也相当于写好了一份类型,强烈推荐在 Agent 项目中引入该库

const weatherSchema = z.object({
  city: z.string().describe("城市名称,如 Beijing"),
  unit: z.enum(["celsius", "fahrenheit"]).optional().describe("温度单位"),
})

type WeatherSchema = z.infer<typeof weatherSchema>

最后再次强调,只要你的 Agent 出现需要理解某个数据结构的含义时,无论是要求模型传入期望的参数,输出期望的结果,或是在工作中理解你传入的某个字段信息,你都应该采用 JSON Schema 的写法


编写风格

由于提示词的编写往往带有个人风格,这里仅提供一些参考建议

如果一开始无从下手,一个比较好的策略是参考所用模型的官方训练提示词风格,比如采用的模型是 GPT ,可以搜索与之相关的提示词示例 (如 system-prompts-and-models-of-ai-tools 这个仓库收集了多个平台的模型提示词和设置),与模型训练阶段采用的提示词保持一致风格,通常更容易被模型理解和执行

目前提示词编写风格有多种,这里主要介绍两类主流写法:Markdown 和 Yaml

这两类提示词都可以写在对应的文件中(如 .md 或 .yml),然后通过插件读取、执行插值操作,再生成最终 Prompt 字符串,不过个人体验下来,直接在代码文件中以字符串变量方式声明提示词开发体验更好,因此这里不再展开从外部文件读取提示词的方案

下面简单用示例介绍下不同风格的写法:

字符串方案推荐使用 ts-dedent 库来进行缩进空白符管理

const systemPrompt = dedent`
  # 首要说明
  以下内容采用 Markdown 风格展示,以清晰表达系统角色、任务逻辑与输出规范
  模型不需要真正解析 Markdown,只需根据标题与列表层级理解语义结构
  带有「」符号的内容表示该文字引用了当前提示中的其他部分

  ---

  ## 角色定义
  你是一个智能助手,负责理解用户意图,选择工具并完成任务
  你的回复风格应当:
  - 语气平和
  - 不含攻击性或歧视性表达

  ---

  ## 任务说明

  ### 理解数据
  - 你需要了解当前时间 {now_time},默认时区为中国

  ### 了解自身能力
  - 提供 MCP 作为扩展能力,你可以根据具体场合随时调用以满足回复需要,具体参考「扩展功能」

  ---

  ## 扩展功能

  ### prisma.conversation.getAgentInput
  - 描述: 获取当前对话上下文
  - 触发条件: 当对话开始或在任务规划过程中需要额外上下文信息时使用

  ### prisma.user.getUserInfo
  - 描述: 获取用户信息
  - 触发条件: 当需要了解到具体的用户信息时

  ---

  ## 输出结构
  说明: 这是你最终回复产生的数据结构,需要严格按照结构输出,否则会造成整个系统报错
  Schema: {output_schema}

`
const systemPrompt = dedent`
  [首要说明]
    - 以下内容使用 YAML 样式结构以突出任务层次与逻辑关系
    - 模型无需真正解析 YAML,仅需依据缩进和键名理解语义
    - 使用「」符号包裹的内容表示引用当前提示词中的部分内容

  role:
    persona: 你是一个智能助手,负责理解用户意图,选择工具并完成任务
    style: 
      - 语气平和
      - 不含攻击性或歧视性表达

  task:
    理解数据:
      - 你需要了解当前时间 {now_time},默认时区为中国
    了解自身能力:
      - 提供 MCP 作为扩展能力,你可以根据具体场合随时调用以满足回复需要,具体参考「mcp」

  tool:
    prisma.conversation.getAgentInput:
      description: 获取对话上下文
      trigger: 当对话开始时需要拿到对话上下文和规划任务途中发现需要更多上下文时

    prisma.user.getUserInfo:
      description: 获取用户信息
      trigger: 当需要了解到具体的用户信息时

  output:
    description: 这是你最终回复产生的数据结构,需要严格按照结构输出,否则会造成整个系统报错
    schema: {output_schema}

`

总之,风格可以完全根据自己喜好决定,只要能让 Agent 理解并完成任务的,那就是一份好的提示词 ~


LangChain

Langchain 提供了 AI解答页面 解答使用过程中的各种问题,使用中遇到不理解的直接问官方 AI 即可

在上面内容的实现中,已经通过直接向模型发送接口请求,手动组织 prompt 和 tool 调用的过程,基本构建了一个最原始的 Agent 结构,但这种方式存在几个明显的维护痛点:

  • 工具管理复杂:需要手动封装每个工具的输入输出逻辑,并在模型调用后回填执行结果,再重新拼接到上下文中发送下一次请求
  • 缺乏任务控制流:当使用多个工具协同完成任务时,需要手动控制每一步的调用与决策循环

因此,在真实的 Agent 项目中,我们需要引入成熟的方案来协助我们完成 Agent 任务调度的功能,这里使用 LangChain 来展开介绍,除了它仍有其他方案,具体可以自行选择

LangChain 的核心功能通常包含:

  • 工具管理自动化: LangChain Agent 自动接收模型决策、执行工具调用,并将结果回填给模型继续推理,不再需要手动管理 模型决策 → 工具执行 → 观察回填 的循环过程
  • 控制流标准化: LangChain Agent 内置 推理循环 的能力,即 Reason + Act (Reasoning and Acting) ,简单来说它的功能就是令 Agent 可以像人一样边想边做,边做边调整策略,即 模型分析上下文 → 决定工具 → 执行 → 观察 → 重复直到完成 这个过程,在使用一些平台模型的过程中,经常发现模型执行过程中念叨的例如 我需要先查询用户输入的城市天气 -> 调用 getWeather 等内容就是模型在执行这个过程
  • 提示词模板: 注入到 Agent 中的提示词会自动识别内部的 {} 语法,在正式调用过程中可以很方便的进行插值替换

LangChain 还有许多便捷的工具,但其他功能后续内容不会提及,这里不再展开,有兴趣自行了解

import { createAgent } from 'langchain'
import { MultiServerMCPClient } from '@langchain/mcp-adapters'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { ChatOpenAI } from '@langchain/openai'

const model = new ChatOpenAI({
  model: "gpt-4o-mini",   
  temperature: 0,
  // ...
})

const mcpClient = new MultiServerMCPClient({
  mcpServers: {
    webSearch: {
      transport: 'stdio',
      command: 'node',
      args: ['tsx', 'src/mcp/web-search.ts']
    }
  }
})

const mcpTools = await mcpClient.getTools()	   // 自动拉取所有注册 mcp 工具列表

const agent = createAgent({
  model,
  tools: [...mcpTools],
  middleware: [...]    // 这里可以放中间件监控mcp运行情况,自行了解,这里不展开
})

// 创建模板
const promptTemplate = ChatPromptTemplate.fromMessages([
  ['system', '你是一个使用 MCP 工具的智能助理'],
  ['human', '用户输入{user_input}']
])

const messages = await promptTemplate.formatMessages({ 
  // 插值传递
  user_input: '搜索今日新闻'
})

// 发起对话
const reply = await agent.invoke({ messages })

上面几行代码,已经完成了 Agent 的声明与工具注册

在对话过程中, Agent 会结合系统提示词与用户输入,由模型决定是否调用 MCP 工具、选择哪个工具、以及如何使用返回结果,整个工具调用与回调循环(Reason → Act → Observe)由 LangChain 框架自动执行,无需显示管理


功能扩展

在引入 LangChain 后,现在可以很方便地去扩充 Agent 的功能,比如 Agent 无法联网搜索,那么就注册一个联网搜索工具,内部调联网接口,如果 Agent 无法识别图片,那就注册一个视觉理解工具,内部调用视觉理解大模型即可……搭配上合理的提示词,Agent 在工作中便会根据具体的用户输入,来自行决定是否调用这些注册的工具,整个过程就和搭积木一样,可以随时扩展而无需更改核心 Agent 代码

那么,这里主要想提的一点是,注册的工具我们是选择 Function Call 还是 MCP 形式

为了便于阅读理解,这里再补充下简单的例子,说明在 LangChain 中注册 Function Call 的区别

import { tool } from "@langchain"
import { z } from "zod"

const webSearchTool = tool(
  ({ query }) => {
    // ...联网搜索实现
  },
  {
    name: "web_search",
    description: "联网搜索工具,当用户询问实时内容,或者问题内容你不确定真实性时调用",
    scheam: z.object({
      query: z.string().describe('搜索关键词')
    })
  }
)

const agent = createAgent({
  // ....
  tools: [webSearchTool]
})

可以看到,虽然注册 Function Call 比 MCP 方便,但是在对于比 stdio 传输模式的 MCP 服务中,他们并没有本质区别,都是本地实现一个函数,然后交给 Agent 去调用,什么场景下使用不同的实现呢,这里直接给个简单的结论:建议全部采用 MCP 服务的方式注册工具,为何下此暴论?以下是自己在开发过程中的实际体验:

Function Call 的提示词更加复杂

再复述一遍定义 —— Function Call 是告诉模型 "你该怎么做",而 MCP 则是 "你有哪些能力":

  • Function Call: 每次需要靠 Prompt 显示传入工具定义,模型只是临时执行这些指令,没有持久上下文
  • MCP:在协议层注册工具清单,把工具能力直接写入模型上下文中,让模型长期知道具备哪些能力,并依据语义和上下文自行判断何时调用

以上面提过的 循环推理 为例,用户一次输入,Agent 内部会发起各个工具的调用,从模型层面来看,它的每一轮推理都会是一个全新的接口调用,意味着每一轮调用时,LangChain 重新把当前 prompt、上下文、工具描述打包给模型并在消息内容中附带上轮工具的执行结果

这就导致了如果 Prompt 中没有具体的工具描述和调用时机,Function Call 将会无法工作

扯了那么多,其实只是为了说明,提示词将会变的复杂,具体看下面提示词例子:

// Function Call 提示词
const functionCallPrompt = dedent`
[角色]
你是一个客户服务助手

[工具]
工具名: get_order_status
触发条件:
- 当用户提到"订单""发货""物流"时调用
- 当用户询问"我的订单现在在哪里""什么时候能到""有没有发货""物流信息"时调用
- 当用户提供订单号或使用"查""查看""追踪""跟踪"等动词时调用
- 当系统提示有未完成订单、需确认收货状态时调用

工具名: cancel_order
触发条件:
- 当用户提到"取消订单""不想要了""退单""帮我撤掉订单"时调用
- 当用户询问"能否取消""我要退款""订单能改吗"时调用
- 当订单状态为“未发货”且用户表达取消意图时调用
`





// MCP 提示词(在编写mcp server 时已经声明工具,agent 自动获知)
const mcpPrompt = dedent`
[角色]
你是一个客户服务助手

`

这只是简单示例,但是在真实场景中,往往存在多个工具,工具之间还可能存在顺序调用,提示词的管理会更加复杂

Function Call 会增加模型心智负担

根据上面定义可以了解到,使用 Function Call 在真实场景中会在 Prompt 里写入很多条件判断,而每次循环推理过程又会重新把这些提示重新让 Agent 理解一遍,所以往往会比使用 MCP 方式耗时更久,最常见的后果:

  • 复杂的依赖条件会导致 Agent 需要更多时间理解输入内容,如何调用工具,最终可能会接口超时
  • Agent 没有按照预期调用某个工具,需要不断修改强化提示词,直到模型行为满足期望
  • Token 使用成本增加

设计上下文

在上面的例子中,用户输入都是一个简单的字符串,但是在真实场景中,这种情况几乎不会发生:

  • 用户输入往往是多模态的,除了文本,还可能携图片、音频、文档……等等内容,这些都属于 Agent 需要理解的上下文内容
  • 真实工程中往往需要 Agent 理解更多信息以作出合理的回复,例如用户信息

基于此,首先我们需要设定一个合理的输入结构,例如

const messageContentSchema = z.array(
  z.discriminatedUnion("type", [
    z.object({
      type: z.literal("text"),
      content: z.string().describe("文本内容")
    }),
    z.object({
      type: z.literal("img"),
      src: z.string().describe("图片的线上地址"),
      content: z.string().optional().describe("可选的图片描述信息")
    }),
    z.object({
      type: z.literal("audio"),
      src: z.string().describe("音频资源链接"),
      content: z.string().optional().describe("音频文件的简要说明")
    }),
    z.object({
      type: z.literal("video"),
      src: z.string().describe("视频资源链接"),
      content: z.string().optional().describe("视频的说明文本或占位符")
    }),
    z.object({
      type: z.literal("file"),
      src: z.string().describe("通用文件的资源链接"),
      content: z.string().optional().describe("文件的描述信息")
    }),
  ]).describe('文本信息示例: {"type": "text", "content": "你好"}; 媒体消息示例: {"type": "img", "content": "[图片]", "src": "https://example.com/1.png"}')
).describe("单条用户消息结构:由若干消息片段组成,每个片段代表不同模态内容,组合后构成完整语义输入。");


const agentInputSchema = z.object({
  conversationId: z.string().describe("当前对话流 ID"),
  messageId: z.string().describe("消息唯一 ID"),
  userId: z.string().describe("发送者的唯一用户 ID"),
  userName: z.string().describe("用户昵称"),
  message: messageContentSchema,
  timestamp: z.string().describe("用户输入时间,ISO 8601 格式"),
})

const agentContext = z.array(agentInputSchema).describe("对话上下文")

这套方案的大致流程链路是:

  1. 用户输入内容
  2. 后端服务监听到用户输入,向数据库中分别推送数据,包括用户数据、消息结构、对话流信息等
  3. 仅将 messageId 和 conversationId 作为输入发送给 Agent
  4. Agent 开始执行,判断需要获取上下文,便自动根据内部写好的构建用户输入 mcp 和构建对话流上下文 mcp 返回标准的输入和上下文结构
  5. Agent 为了理解上下文特殊媒体信息,分别调用不同的工具,如图片理解、音频理解、文档读取等 mcp 进行辅助理解
  6. 最终输出答复

可以看到,我们真正要帮 Agent 做的,只是存储必要的数据,而后续整个流程都直接交由 Agent 决策

这样做的好处是其实是把每个功能块都分开维护了,而不像传统的在进行 Agent 回复时,手动读取所需数据,读取完毕后再传给 Agent 开始执行

例如用户信息含有不同媒体,就先检索出不同的媒体,然后再根据媒体类型进行一一处理(例如调用视觉理解大模型返回图片描述),然后最终汇总成一个概要信息,传递给 Agent 充当原始输入

当然,上述方案只是个人实践中采用的一种设计思路,在不同的使用场景下,Agent 获取与理解上下文的方式可以有多重实现路径,具体的架构可以根据具体业务场景调整


记忆管理

最后来介绍 Agent 的一个核心功能 —— 记忆能力,一般来说 Agent 记忆体系分为两大类:

  • 短期记忆:存储在运行内存中,用于维持最近几轮对话的上下文,让 Agent 在当前会话中保持连贯性
  • 长期记忆:存储在外部容器(如数据库或向量数据库)中,用于记录用户档案、历史任务、重要事件等,可跨对话持续使用

为了让 Agent 可以根据用户输入提炼出最符合条件的记忆,我们这里引入 RAG(Retrieval-Augmented Generation) 功能,即检索增强生成

简单来说,我们要做的事情就是:

  • 当用户提及需要被记住的事情时,Agent 会将这些信息保存到长期记忆中
  • 当用户提及要回忆的事情时,Agent 会借助 RAG 机制,从存储的长期记忆中检索出与当前语义最相近的记录,以补充上下文生成更加贴合的回复

即长期记忆负责存,RAG 负责找

为了达到这个目的,我们需要两个东西:

  • 能将原始文本转换成向量的向量大模型
  • 专门存储向量的向量数据库

向量大模型各大平台都有提供,这里不展开,主要关注向量数据库,由于目前网上平台没有适合个人使用的向量数据库,我们选择在自己服务器中搭建向量数据库,这里推荐使用 Chroma ,因为它提供了 Node 的快速部署方式还有 SDK 介入使用,如果有其他方案可自行选择

向量模型和向量数据库每个人方案都可能不同,这里不专门介绍

现在假设我们已经有了转换向量和存储向量的能力,那么此时我们会不可避免地遇到一个问题:我该存什么内容?

其实这并没有一个确切的答案,因为每个 Agent 的具体要关注的内容点要跟着具体业务场景走,但是可以提供一个小例子帮助功能理解,例如做一个聊天功能方向的 Agent,那么此时应该关注的是用户信息,比如:

  • 用户基本信息:名字、年龄、职业等
  • 用户偏好:喜欢或讨厌的事物
  • 用户习惯:作息、行为模式
  • 重要事件:值得记住或后续可能引用的互动片段

为了能让 Agent 可以随着对话深入更改自身风格,我们也可以同时设定与 Agent 有关的记忆,比如:

  • 说话风格:语气、用词习惯、典型表达方式
  • 情绪状态:当前心情、情绪变化、对用户的亲近感

那么综合下来,我们可以写出一份 Prompt 辅助 Agent 理解

const systemPrompt = dedent`
[角色]
你善于与人交流,懂得根据不同用户调整回复

[记忆功能]
功能说明:长期记忆的存储与召回,用于记住用户偏好、重要事件、以及 Agent 自身的动态人设变化

触发条件: 
  - 存储:当对话中出现值得长期记住的信息时
  - 召回:当需要回忆过往信息辅助回复时

执行要求:
  存储记忆时机:
    - 用户透露了个人信息(名字、职业、生日等)
    - 用户表达了明确的偏好(喜欢/讨厌某事物)
    - 发生了重要的对话事件(约定、承诺、特殊互动)
    - 与用户的关系发生变化(称呼变化、亲密度变化)
    - Agent 自身的人设需要演变(情绪变化、观点更新)

  召回记忆时机:
    - 用户询问"你还记得吗"、"之前说过"等回忆性问题
    - 需要根据用户偏好来个性化回复
    - 需要保持对话连贯性,引用过往信息

  记忆分类规则:
    - type: 选择 'user'(关于用户的记忆)或 'agent'(关于自身的记忆)
    - tag: 根据内容选择合适的标签
      - user 类型可选: profile/preference/habit/relationship/event
      - agent 类型可选: persona/style/emotion/opinion/event
    - importance: 根据信息重要程度评分 0.0~1.0
      - 0.8~1.0: 核心信息(名字、重要约定)
      - 0.5~0.7: 一般偏好(喜欢的事物)
      - 0.3~0.4: 临时信息(当前情绪)
    - emotionScore: 情感强度 0.0~1.0,情绪激动的记忆更持久
    - decayRate: 衰减速率,越大遗忘越快
      - 0.005: 人设/画像(很持久)
      - 0.02: 偏好/习惯(正常)
      - 0.05: 情绪/事件(较快遗忘)

注意事项:
  - 不要重复存储相同的记忆
  - 召回结果按综合得分排序,优先使用高分记忆

`

现在可以存储和提炼记忆了,但是在真实场景中,随着对话进行,存储的记忆会愈多愈杂,为了确保 Agent 在回忆时始终提取到那些"最重要,最有代表性"的记忆,我们通常还需要引入机制 —— 记忆强化(Memory Reinforcement)与记忆衰减(Memory Decay)

记忆强化

记忆强化用于模拟人类 "越常提起,记得越牢" 的机制:

  • 当用户或 Agent 在对话中多次引用同一段记忆时,该记忆会被重新激活,其重要度或权重得到提升
  • 每当 Agent 检索并使用某条记忆时,该条记忆的权重分值会被提升或重置其衰减倒计时
  • 被频繁触及的记忆会逐渐转化为长期记忆

记忆衰减

记忆衰减用于模拟人类记忆中 "遗忘" 的概念:

  • 不同记忆依据其重要性与情绪强度,被赋予不同的生命周期
  • 长期未被引用或价值较低的信息,会逐渐降低匹配权重甚至被清除
  • 核心事实与高情绪信息(如名字、强烈事件)则会在衰减曲线中维持更久

通常实现是在后台维护一个定期任务:

  1. 定期(如每24小时)扫描库中记忆,
  2. 根据时间衰减算法重新计算每条记忆的有效得分
  3. 若记忆得分过低,则执行降权、归档或删除操作

以下是一个简化版的衰减示例(真实项目中会更复杂,仅做辅助理解):

/**
 * 模拟人类长期记忆的衰退与强化机制
 */

const DECAY_CONFIG = {
  accessBoostFactor: 0.15,      // 访问加成影响力
  emotionBoostFactor: 0.25,     // 情绪加成影响力
  minRetentionThreshold: 0.2,   // 低于此值记忆将被归档
  checkIntervalHours: 24,       // 每隔多少小时执行一次衰减评估
};

// 记忆对象示例
const memory = {
  id: 'user_pref_001',          // 记忆ID
  importance: 0.8,              // 初始重要度(0 ~ 1)
  decayRate: 0.03,              // 衰减速率(越大遗忘越快)
  accessCount: 12,              // 被访问次数(强化指标)
  emotionScore: 0.6,            // 情绪强度(0 ~ 1)
  associationCount: 5,          // 被其他记忆引用的次数
  lastAccessedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), // 上次访问时间(5天前)
  weight: 0.9,                  // 当前权重值(动态更新)
};

/**
 * 计算单条记忆的当前有效度(Retention Score)
 * @param {Object} memory - 记忆对象
 * @returns {number} 当前有效度(范围:0~1)
 */
function computeMemoryRetention(memory) {
  const now = Date.now();
  const lastAccess = memory.lastAccessedAt.getTime();

  // 计算距离上次访问的天数
  const daysSinceAccess = (now - lastAccess) / (1000 * 60 * 60 * 24);

  // ========== 1️⃣ 时间衰减:e^(-decayRate × 天数) ==========
  const timeDecay = Math.exp(-memory.decayRate * daysSinceAccess);

  // ========== 2️⃣ 访问强化:访问次数越多,记忆越稳固 ==========
  // 对数函数抑制增长过快
  const accessBoost = 1 + Math.log(1 + memory.accessCount) * DECAY_CONFIG.accessBoostFactor;

  // ========== 3️⃣ 情绪加成:情感浓度高,记忆更深刻 ==========
  const emotionBoost = 1 + memory.emotionScore * DECAY_CONFIG.emotionBoostFactor;

  // ========== 4️⃣ 关联强化:与其他记忆共现频繁则更稳固 ==========
  const associationBoost = 1 + (memory.associationCount || 0) * 0.01;

  // ========== 5️⃣ 综合计算:融合时间、情绪、访问和重要性 ==========
  let retentionScore = memory.importance * timeDecay * accessBoost * emotionBoost * associationBoost;

  // 归一化结果,控制在 0~1 区间
  retentionScore = Math.min(1, Math.max(0, retentionScore));

  return retentionScore;
}

/**
 * 模拟每日衰减更新任务
 * 对所有记忆执行衰减评估,更新权重或归档
 */
async function decayMemoryDatabase(memories) {
  for (const mem of memories) {
    const score = computeMemoryRetention(mem);
    mem.weight = score; // 更新实际权重

    if (score < DECAY_CONFIG.minRetentionThreshold) {
      // 低活跃度记忆归档
      archiveMemory(mem);
    } else {
      updateMemory(mem);
    }
  }
}

// 模拟归档函数
function archiveMemory(mem) {
  console.log(`🗃️  归档记忆:${mem.id}(score=${mem.weight.toFixed(2)})`);
}

// 模拟更新函数
function updateMemory(mem) {
  console.log(`💾  更新记忆:${mem.id}(score=${mem.weight.toFixed(2)})`);
}

// 运行示例
decayMemoryDatabase([memory]);

可以看到,为了引入记忆强化和记忆衰减,我们往往需要频繁地更新记忆属性,如果我们把这些数据都存放在向量数据库的 metadata 中,修改会较为复杂,因此我推荐采用数据库+向量数据库混合架构:

  • 向量数据库:用于存储向量表示(embedding),描述文本(document)以及只用于检索过滤的静态属性 metedata 中(如类型、用户 id, 创建时间等)
  • 数据库:用于存储与记忆对应的可变特征,如权重,访问次数,情绪分值,衰减速率等,每条数据 ID 与向量库中记忆 ID 一一对应
  • 更新方式:当 Agent 检索或修改记忆时,只需更新数据库中关联记忆的可变属性,而不必重新写入向量数据库条目,检索时,则先取出数据库记忆特征,与向量数据库记忆合并再传递给 Agent

结语

由于目前开发者几乎都能使用各类 AI 工具,本文不再赘述实现细节,只分析个人在实际应用中的经验、设计方案与关键概念,希望能为后来者提供一些可参考思路~