从零到一:如何自建 Agent?阿里工程师的实践分享

本文分享了使用 LangGraph 和 FaaS 平台自建智能编码 Agent 的实践经验,涵盖技术选型、知识库、提示词优化和上下文管理等关键环节。

原文标题:自建一个 Agent 很难吗?一语道破,万语难明

原文作者:阿里云开发者

冷月清谈:

本文详细介绍了作者自建 Agent 以集成到内部研发平台的实践经验,旨在为希望自建 Agent 的开发者提供参考。文章涵盖了技术选型、应用框架选择(FaaS、Next.js + React、LangGraph)、方案落地、系统提示词优化(角色设定、XML、示例)、知识库建设(线上热门脚本、系统内置字段、服务端代码理解)、工具接入(远程工具、本地工具)以及上下文管理(连续对话、会话压缩)等多个方面。通过结合 LangGraph 抽象的状态图和自身平台的工具,构建了一个能够感知宿主页面环境、进行代码编辑和执行的智能助手。作者还分享了在提示词优化、知识库建设和上下文管理方面的具体技巧和经验,包括利用 XML 格式化提示词、清洗和拆分知识库数据、以及采用工具结果缓存和上下文压缩等方法来提高模型性能和节约 token 消耗。此外,还提供了处理 Faas 不支持长连接的解决方案,以及实现连续对话和会话中断恢复的策略。

怜星夜思:

1、在自建 Agent 的过程中,选择合适的知识库类型和组织形式至关重要。除了文中提到的线上热门脚本、系统内置字段和服务端代码理解,还有哪些类型的知识可能对 Agent 更有帮助?如何确定不同类型知识的优先级?
2、文章中提到了使用 XML 格式来优化提示词,并给出了多个 XML 模板示例。除了文中提到的全局函数、领域模型字段和内置指令,还有哪些信息适合用 XML 结构化地呈现给模型? 这样做的好处是什么?
3、文章中提到,Faas 环境不支持长连接,作者通过一种“曲线救国”的方式实现了 UI 工具的调用。这种方案的核心思路是什么? 如果不考虑 Faas 的限制,你认为更优雅的解决方案是什么?

原文内容

最近花了一周左右的时间给内部的一个传统研发平台接入了 Agent 开发的能力,很多同学对 Agent 的底层实现非常感兴趣,所以此篇给大家介绍下我是怎么做的,希望能对想自建 Agent 的同学有所启发。

因人力原因,有些细节方案问题没太做深度评测,而是直接选择业界实践较多的成熟方案。主要参考思路和上下文管理的过程。


文中用到了一些内部平台的基础能力,比如 rag、代码管理、deepwiki等,外部开发者如需使用需要自行寻找替代品。

背景简介

奥德赛研发平台是 ICBU 买家技术的 TQL(淘宝基于开源 GraphQL 的定制版本) 研发平台,大量开发者会在上面通过编写 TQL 脚本来实现 BFF 接口。

近一年 AI Coding 工具层出不穷,在深度使用了 cursor、claude code 等顶尖产品后,大量解放了自己在前端的生产力,所以就在想让团队的后端兄弟(还有姐妹)们也吃好点,告别纯手搓代码,这不,BFF 的 Agent 模式(小 D 同学)来了~

技术选型

原来的平台长这样,开发者在上面可以完成编码、调试、发布等工作:

要在现有的平台内集成一个 Agent ,且能感知前台页面的环境,甚至对页面进行操作,一般有三种方式(默认都采用 AI 辅助 Coding):

如果只是一般的对话 Agent,直接用一些开放的应用平台搭建就完事了,不必自己写。

综合考量了三种方案后,我们决定优先保证用户体验&开发效率,选择了第三种。

宿主页面 Iframe 嵌入 Agent

整体的数据流转概要:

1. 宿主页面暴露 脚本内容、请求参数、调试结果 等上下文信息,以及脚本执行、脚本 Diff 预览等操作页面的工具接口给 Agent。
2. Agent 感知宿主页面上下文环境,然后请求服务,并推送内容编辑、脚本执行等工具执行 action 给宿主页面。

流程概要图如下:

应用框架选择

应用框架选择上不做过多对比,直接给出我的选择,不一定最好,但是一定很契合我当前的场景。

集团 Faas 基建

简介:FaaS(Function as a Service,函数即服务)平台是一个面向研发人员的全托管、事件驱动、弹性伸缩的 Serverless 计算基础设施,其核心目标是让开发者只关注业务逻辑代码本身,无需操心服务器运维、资源扩缩容、中间件对接等底层细节。

划重点,Faas 可以让你只关注代码,免运维。这正是当前这种轻量 Agent 需要的。

Next.js + React

前后端应用框架我选择了 Next.js + React,为什么?

1. 集团有前后端一体化的框架可以直接用。
2. 前后端共用一套开发语言(Nodejs、JavaScript),并集成在一个应用中,AI Coding 非常友好,谁用谁知道。可以真正做到只关注功能,AI 帮你做实现。
LangGraph

LangChain [1]团队在 WorkFlow/Agent 领域摸爬滚打了几年,高度抽象了 Agent 的开发模式。

LangGraph 其巧妙设计让你可轻松构建一个状态图,你可以只关注 系统提示词、工具节点(通常是 mcp)就可轻松实现一个会自主决策的 Agent。手残党友好。

方案落地

应用框架初始化

如上文所述,结合当前场景,把 LangGraph 抽象出来的状态图展开,替换成自己的工具,就得到这样一个图和伪代码:

此处了解个大概就可以了,稍后会详解工具

BFF Agent 状态图:

伪代码:

import { StateGraph, END, START } from '@langchain/langgraph';

// 创建状态图
const agentGraph = new StateGraph({
  channels: {
    messages: {
      reducer: (prev, next) => […prev, …next],
      default: () => ,
    },
  },
});

// 添加节点
agentGraph.addNode(‘agent’, async (state) => {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
});

agentGraph.addNode(‘tools’, async (state) => {
  const lastMsg = state.messages[state.messages.length - 1] as AIMessage;
  const toolMessages = ;
  for (const toolCall of lastMsg.tool_calls || ) {
    const result = await tools
      .find((t) => t.name === toolCall.name)
      .invoke(toolCall.args);
    toolMessages.push(
      new ToolMessage({ content: result, tool_call_id: toolCall.id }),
    );
  }
  return { messages: toolMessages };
});

// 设置边
agentGraph.addEdge(START, ‘agent’);
agentGraph.addEdge(‘tools’, ‘agent’);

// 条件边:根据是否有 tool_calls 决定路由
agentGraph.addConditionalEdges(
  ‘agent’,
  (state) => {
    const lastMsg = state.messages[state.messages.length - 1] as AIMessage;
    return lastMsg.tool_calls?.length > 0 ? ‘tools’ : END;
  },
  { tools: ‘tools’, [END]: END },
);

// 编译图
const graph = agentGraph.compile();


参考 LangGraph 的官方文档的介绍,用 Cursor 可以轻松实现上述一个基础的有向、有环状态图,至此,BFF Agent 服务的基础骨架就好了。模型选择上,由于需要生码,我还是直接使用了 Claude (Claude-4.5-haiku)的模型。

那么接下来的重点就是从能用到好用,后续的内容非常关键。也就是上下文工程的优化,接下来的优化是不分顺序的,因为上下文工程是一个综合命题,比如你往往需要在系统提示词优化完成后,发现某些工具调用不符合预期,又回过头来优化系统提示词。不同类型的上下文往往是交叉影响的,要根据具体场景做甄别。

系统提示词优化

Anthropic 官方推荐了提示词优化[2]的诸多技巧,非常有效!下面介绍我高频用到的几个基础技巧,更多技巧真的强烈建议直接学习原文。

1. 角色设定

角色设定可以显著提高模型的性能(参考Qwen 的 MOE 机制),并改进模型的注意力,发现更多的关键问题。

2. 使用 XML 

Anthropic 官方推荐使用 XML 构建提示词[3],有诸多好处,实测非常有效。遇到模型不遵循指令,或上下文过长,出现遗忘/幻觉时,试试更换提示词格式为 XML。

3. 使用示例

也就是 few-shot ,给少量准确的示例,尤其对输出内容改善上有重大帮助。还有,一篇 research [4]有介绍,尽量直接给模型正例而不是反例,保护模型的注意力。(比如我告诉你不要去想一会要吃什么,你反而会刻意去想如何不要去想这件事情,就浪费了你的注意力)

上述三个技巧是非常简单且行之有效的方法,全程指导了我去优化提示词,来看具体怎么用的 👇

角色设定 & 使用 XML

淘宝的 TQL 本质上是一种 GraphQL 的方言,ICBU 还又在 TQL 的基础上做了定制,大模型是不可能会写的:

模型不了解私域知识这个问题,在自建 Agent 的时候往往是一个共性问题,所以才需要大量的提示词 & 工具 & 知识库。强如 Claude ,公司自己的 Qwen ,都不知道 TQL 的含义。

好在,模型不用从 0 开始,它会写 GraphQL,那么只需要阐述清楚二者的区别。不只是提示词,后续的知识库也是尽力在给模型解释私域知识。小 D 的提示词摘要:

<role>你是小 D 同学,一个专业的 TQL 脚本编写助手,TQL 是淘宝基于 GraphQL 扩展的查询语言,你只会写 TQL 语法,不会任何其它脚本。你的任务是帮助用户基于企业知识和用户输入,编写高质量的 TQL 脚本。</role>
<instructions>
  <instruction>你非常欠缺 TQL 知识,但好在系统内置了很多工具,你可以充分使用这些工具来辅助你完成任务。</instruction>
  <instruction>系统在开源 GraphQL 的基础上,扩展了很多自定义的指令,如果你遇到不确定或无法实现的需求,可充分用工具查询相关知识</instruction>
  <instruction>回答要专业、友好、有条理。使用 Markdown 格式输出。</instruction>
  <instruction>在编写 TQL 脚本时,要确保语法正确、查询结构清晰、字段选择合理。</instruction>
</instructions>
  • 角色设定上,让模型更多关注 GraphQL 方向的知识,并且申明 TQL 和 GraphQL 是有区别的,让模型谨慎操作。
  • instructions(命令)上,第一条命令比较有意思,在没有这条命令之前,模型会过于自信(即便已经是最严谨状态 temperature=0),拿到用户需求后,直接开始上手写 GraphQL ,出现了与 TQL 非常不相符的代码。用第一条指令弱化了模型的信心,模型才开始谨慎地调用知识库了解更多上下文信息。

TQL 在 GraphQL 的扩展内容部分,为了防止提示词内容爆炸,我做了一轮精简,只保留基础介绍部分(索引知识),放在提示词中,并引导模型在使用到具体能力的时候,动态通过工具查询具体知识,从而保护了模型的注意力。

自定义指令:

<directives>
    非常重要!系统有大量的扩展指令,具体用法需要通过工具查询相关知识,
    在使用这些指令时,一定要先学习指令的用法,不要盲目使用。

    指令一般紧挨着字段或函数, fieldA @指令名 或 funcA(xx) @指令名 这样使用。
    
    以下是常用指令(格式: “指令名”:“描述|参数”):
                                                                <![CDATA[{
      “转换类”:{“unwrap”:“解包/对象拍平/对象解构”,“toBool”:“转布尔|not”,“encode”:“编码|protocol=http”,“wrap”:“包装”,“toInt”:“转整数|offset”,“toUpperCase”:“转大写”,“decode”:“解码|protocol=http”,“autoflat”:“自动扁平化”},
      “字符串操作”:{“suffix”:“添加后缀|value”,“prefix”:“添加前缀|value”},
      “条件过滤”:{“hide”:“隐藏字段”,“filterBy”:“表达式过滤|spel,aviator”,“include”:“条件包含|if!”,“skip”:“条件跳过|if!”,“default”:“默认值|value”},
      “列表操作”:{“index”:“取元素|offset”,“ascBy”:“升序|path”},
      “逻辑脚本”:{“mapping”:“映射|func”,“const”:“常量|value,beforeExecute=false”},
      “高级扩展”:{“medusa”:“Medusa服务|url,language”,“diamond”:“Diamond服务|url”}
    }]]>
  </directives>


全局函数:

<TQLFunctions name="系统内置的全局函数">
    <description>以下是 TQL 脚本中可直接使用的内置函数,详细用法请通过知识库查询。</description>
    <![CDATA[
    【字符串处理】
    - QL_concat: 拼接三个字符串(a, b, c参数)
    - QL_string_replace: 字符串替换(replaceText, searchString, replaceString)
    - QL_stringToList: 字符串按分隔符转列表(data, delimiter)
    - QL_stringToJSON: 字符串转JSON对象
    - QL_jsonStringify: JSON对象转字符串
    - QL_joinStringByPath: 通过JSON Path提取属性并拼接(object, path, delimiter)
    - QL_urlDecode: URL解码
    - QL_addHttpsSchema: 自动添加或转换为https协议头
    - QL_addUrlParam: 给URL添加参数(url, param)

    【数值计算】
    - QL_sumLong: 两数相加(addition1, addition2)
    - QL_subtraction: 两数相减(minuend, subtrahend)
    - QL_divideInt: 整数除法向下取整(dividend, divisor)
    - QL_random_int: 生成指定范围随机整数(min, max)

    【条件判断】
    - QL_if: 三元条件判断(condition, output, orElse)
    - QL_conditional: 复杂条件表达式,支持#env.get()获取变量(exp, params)
    - QL_defaultIfBlank: 空值时返回默认值(str, defaultValue)
    - QL_timestampComparator: 判断当前时间是否在指定时间戳范围内

    【AB测试】
    - QL_abTest: AB实验分流,返回命中的实验桶标识(experiment)
    - QL_batchAbTest: 批量执行多个AB实验

    【数据处理】
    - IDs_fromString: 从字符串解析ID对象,支持商品/类目/供应商/公司/国家(ids)【重要】
    - QL_mergeList: 合并两个列表(aim主列表, tail尾部项)
    - QL_subList: 截取列表子集(base, from, to)

    【国际化】
    - QL_medusa: 美杜莎翻译,获取国际化文案(key)【所有文案必须使用】
    - QL_countryFlag: 获取国家国旗链接(country)

    【数据脱敏】
    - QL_desensitization: 数字脱敏,末位补0(value)

    【输出与重命名】
    - TQL_output: 输出固定对象,包括布尔值、数字、数组、对象(object)
    - 字段重命名: 使用GraphQL别名 或 @hide指令隐藏原字段
    ]]>
  </TQLFunctions>


使用示例

至于示例部分,虽然上面讲到了要给模型一些正例,但我非常不建议一上来就瞎给模型一堆示例,先让模型发挥,在后续调试过程中,对模型容易出错的部分,直接给出正确引导。

比如我一开始遇到模型总是将请求参数硬编码在脚本中,没有抽离成查询参数,我就给了这样的示例:

<rule>
  <title>参数分离原则</title>
  <content>
    建议将 GraphQL 请求的参数单独放在 variables
    中,但要以实际需求为主。如果用户明确要求将参数写死在脚本中,或者参数是固定的常量值,可以直接写在脚本中。
    当需要参数分离时: 
    - 脚本中使用变量定义($variableName) - 参数值通过 editVariables 工具设置到
    variables 中 
    - 使用 editScript 和 editVariables 两个工具分别更新脚本和变量 示例(参数分离):
    脚本:query($userId: String!) { user(id: $userId) { name } } 
    参数:variables:{"userId": "12345"}
    这样做的好处:脚本可复用,参数和逻辑分离,便于维护和调试。
  </content>
</rule>

类似的例子很多,不再展开讲,还是那句话,建议全文背诵 Anthropic 官方的优化教程[2],有的技巧可能初识的时候不以为然也不要紧,但是当你遇到真正问题的时候,就能快速联想到,让你少走弯路。

知识库建设

紧接上文,系统提示词给了部分私域知识片段后,TQL 的详细用法(全局指令、函数)、服务端内部可用的查询接口字段、线上运行中的成熟脚本等等海量的知识,不可能一次性交给模型,Rag 目前最成熟的知识管理方式。小 D 的知识库主要分为下面三大类:

线上热门脚本

通过对线上脚本调用量监控的采集,提取出了 top 100 的脚本。然后分别用 qwen 的小大模型,对脚本做一个初步的理解(wiki),生成一份格式化的文档,帮助模型快速理解脚本含义,简单示例:

Rag 的分片策略很大程度上直接影响了召回的质量。所以我预先对脚本做了切分,每个脚本独立一个文档,再直接导入到 kbase(内部 Rag 平台) 中使用。

(kbase 是 aone 工程团队自建的知识库平台,支持嵌入和通过 mcp 召回知识)

系统内置字段

包括 TQL & ICBU 在 GraphQL 上扩展的 TQL 指令、全局函数,以及服务端的领域模型字段。GraphQL 是一门支持自省的语言,(服务端用注解标注了这些字段,所以上述信息都可以被扫描出来)。然而这几年的膨胀,自省的内容已经长达 600w+ 字符。为了让 Rag 的召回效果好,对数据做了大量的清洗工具,包括:

  • 扫描出全局指令、全局函数、领域模型的全集,然后和线上脚本进行匹配,只保留高频(出现 3 次以上)使用的部分,语义相似的内容,人工介入做区分;功能相同语义不通的部分,选择性保留。
  • 剔除标注废弃的内容。
  • 和热门脚本一样,预先对文档对拆分,全局指令、函数独立文档,内置领域模型适当合并。

清洗的过程非常耗时,需要有细心且耐心,可以借助 cc 的 skills ,帮你写脚本处理数据。

系统内置字段也可用文档管理,方便导入到 Rag 中。

服务端代码理解

上述梳理出来的知识更多是结果,为了帮助模型从源头理解字段背后的含义,TQL 对应的服务端源码也是很好的输入。这部分已经有成熟能力可以直接使用了,如内部 code 平台的 search 工具,还有内部的 deepwiki 平台。由于此前已经在 deepwiki 上解析过服务端应用(winterfell)的源码,且实测下来其 codebase 效果比较理想(不是拉踩哈,建议自行实践),所以选择了它提供的能力做代码片段召回。(据说是因为 deepwiki 使用了 openai 最好的文本嵌入模型。)

工具接入

回过头再来看看 Agent 的工具设计,分为两部分,本地工具和远程(MCP )工具:

远程(MCP)工具:
  • kbase 的 mcp server
  • deepwiki 的 mcp server

远程工具主要用来召回上文知识库建设的内容,由于其提供的工具比较全,而我实际只会用到其中的部分,所以在系统中设计了白名单机制,只加载白名单内的工具,还是那句话节约上下文,保护注意力。

btw,mcp 的鉴权认证需要自行参考官方文档,不做赘述。

本地工具
  • 编辑脚本(editScript)
  • 功能编辑/更新 GraphQL 脚本内容
  • 输入script (string) - 完整的 GraphQL 脚本代码
  • 输出返回更新状态
  • 编辑变量(editVariables)
  • 功能编辑/更新 GraphQL 请求变量(mock 参数)
  • 输入variables (string) - 完整的 variables JSON 字符串
  • 输出返回更新状态
  • 执行脚本(executeScript)
  • 空方法,用于引导模型择机执行 GraphQL 脚本,实际的实现在前端宿主页面上。
  • 验证结果(validateResult)
  • 功能验证 GraphQL 脚本的执行结果
  • 输入
  • prompt (string) - 验证要求描述
  • currentScript (string, optional) - 当前脚本
  • currentVariables (string, optional) - 当前变量
  • 执行结果通过闭包注入(不在参数中传递)
  • 输出返回结构化验证结果(passed、summary、problems、suggestions、needMoreContext、contextQueries 等)
工具调用链路

注册注册到 Agent 中后,常规的工具调用链路如下:

服务端内部的工具调用比较好理解,但服务端的工具调用是怎么触发前端 UI 侧的工具调用呢?

比如 Agent 在调用【执行脚本】的方法时,本质上还需要前端页面响应,点击执行按钮后,然后把执行结果回传给 Agent,Agent再继续处理。

聪明的你肯定想到了:前后端用全双工通信,维护一个长连接,服务端调用 runScript 时,实际上什么也不做,就等待前台页面执行完工具后,将结果传回服务端,服务端再继续后续的状态流转链路。是的,确实如此,很多带 GUI 的工具也确实是这么处理的(在 LangGraph 中称之为中断,也就是人们常说的 HITL(human in the loop))

遗憾的是,Faas 的设计之初是不支持长连接的:

def 上函数能设置的最长保活时间是 300s(默认 50s)。

所以我们肯定得曲线救国了~ 允许我先卖个关子 😁

上下文管理

其实有了上面能力建设,现在已经有了一个可以帮助用户编写脚本,执行,验证的智能体了,但是面临两个非常现实的问题:

1. 模型本身不支持连续对话,会话需要自己做持久化管理
2. 尽管在前期做了大量节约上下文窗口的优化工作,但真正要处理一个复杂脚本的时候,模型上下文窗口会快速膨胀,导致模型注意力变得稀疏,出现幻觉,准确率下降,用户体验下降。

一轮简单的对话,只要包含工具调用,就会耗掉 1/4 (5w)的 token。因为在默认的设计中,每个节点的返回消息,都是默认拼接到 LLM 消息列表中的。

连续对话

先来处理简单的,连续对话实现非常简单。也可以参考 LangGraph  checkpoint [5]的设计,可以从任意节点重新开始。BFF Agent 暂时没用到这个能力。

在用户开始一次新对话时,创建一个 sessionId,用来记录存储消息列表,在消息列表每次发生变化的时候,持久化存储起来(这里我用的 tair),用户有新输入时,直接 sessionId 记录的历史消息列表做拼接,合并发给大模型,就可以实现连续对话了。

UI 工具是如何调用的

在支持连续对话的基础上,就有了一个非常巧妙的设计

1. Agent 的接口设置为 SSE 的方式,服务端在收到请求后,流式向前端推送分片消息,同时,将每轮消息持久化存储起来。(SSE 的方式只支持服务端单向给客户端推送消息)
2. 当调用到需要前台 UI 响应的工具时,在把工具调用信息输出给前端后,直接退出 LangGraph 的状态流转,结束此次请求。是的,直接结束
3. 前端特殊处理该类工具调用后,(含 diff 脚本,接受或拒绝 Agent 的修改,执行脚本等),增加一条隐藏消息(如:【脚本执行成功,结果是xxx,请继续处理】)重新调用 Agent 的流式接口,接口内部取出之前持久化的消息内容,拼接上隐藏消息,从头开始初始化 AgentGraph 的调用。

用户在前台看到的:

实际上发送给模型的:

至此,就实现一个可以自己执行、验证、修复、再执行的智能体。

对应的,会话中断恢复也就不难处理了,因为在服务端完整缓存了会话内容,中断后,只需要发送新消息,接口内部就自动拼接上之前的会话,重新走进 Agent 的状态流转。

会话压缩

解决了连续对话的问题,上下文超长的问题怎么办呢?

现在答案是压缩,只保留摘要信息。(不知道以后模型是否可以自行处理~)

压缩工具响应结果

既然调用外部工具如此废 token,那么如果先将工具的调用结果缓存起来,再用一个工具函数去精准检索内容,并只把检索后的内容放到消息列表中,就能极大的缓解问题了。

于是,我在 Agent 内部增加了工具结果缓存 + 检索详情的 tool (summaryToolResult),在工具调用结束后,增加如下机制:

当然,summaryToolResult 的任务非常明确,普通的模型也有不错的据实回答问题的能力,所以这里选择更轻量的 Qwen 模型做检索召回,还能省不少钱~

为了尽可能让 summaryToolResult 在回答时结构化且保留原始信息,我对工具的提示词作了优化,使用 xml 格式响应。

是的,还是 anthropic 优化提示词的那一招。

<?xml version="1.0" encoding="UTF-8"?>
<prompt>
  <role>你是一个数据提取助手。你的任务是从给定的工具调用结果中,根据用户的查询需求,提取并返回相关的事实信息。</role>
  
  <extractionRules>
    <rule>只返回与查询高度相关的事实信息</rule>
    <rule>保持信息的准确性,不要编造内容</rule>
    <rule>如果找不到相关信息,明确说明</rule>
  </extractionRules>
  
  <specialRules>
    <description>当提取的内容涉及以下类型的功能说明时,必须使用对应的 XML 结构详细输出完整的用法信息:</description>
    
    <featureType name="全局函数">
      <xmlTemplate><![CDATA[
<function>
  <name>函数名称</name>
  <description>函数功能说明</description>
  <parameters>
    <param required="true/false">
      <name>参数名</name>
      <type>参数类型</type>
      <default>默认值(如有)</default>
      <description>参数说明</description>
    </param>
    <!-- 更多参数... -->
  </parameters>
  <returns>
    <type>返回值类型</type>
    <description>返回值说明</description>
  </returns>
  <example>使用示例代码</example>
</function>
      ]]></xmlTemplate>
    </featureType>
    
    <featureType name="领域模型字段">
      <xmlTemplate><![CDATA[
<field>
  <name>字段名称</name>
  <type>字段类型(标量/复合)</type>
  <description>字段描述</description>
  <arguments>
    <arg required="true/false">
      <name>参数名</name>
      <type>参数类型</type>
      <default>默认值(如有)</default>
      <description>参数说明</description>
    </arg>
    <!-- 更多参数... -->
  </arguments>
  <subFields>
    <subField>
      <name>子字段名</name>
      <type>子字段类型</type>
      <description>子字段说明</description>
    </subField>
    <!-- 更多子字段... -->
  </subFields>
  <example>使用示例代码</example>
</field>
      ]]></xmlTemplate>
    </featureType>
    
    <featureType name="内置指令">
      <xmlTemplate><![CDATA[
<directive>
  <name>@指令名称</name>
  <description>指令功能说明</description>
  <locations>
    <location>FIELD</location>
    <location>QUERY</location>
    <!-- 可应用的位置:FIELD, QUERY, FRAGMENT_SPREAD, INLINE_FRAGMENT 等 -->
  </locations>
  <arguments>
    <arg required="true/false">
      <name>参数名</name>
      <type>参数类型</type>
      <default>默认值(如有)</default>
      <description>参数说明</description>
    </arg>
    <!-- 更多参数... -->
  </arguments>
  <notes>
    <note>注意事项或限制</note>
    <!-- 更多注意事项... -->
  </notes>
  <example>使用示例代码</example>
</directive>
      ]]></xmlTemplate>
    </featureType>
    
    <featureType name="自定义标量类型">
      <xmlTemplate><![CDATA[
<scalarType>
  <name>类型名称</name>
  <description>类型说明</description>
  <format>取值范围或格式要求</format>
  <example>使用示例</example>
</scalarType>
      ]]></xmlTemplate>
    </featureType>
  </specialRules>
  
  <outputFormat>
    <rule>涉及功能用法时,必须使用上述对应的 XML 结构输出</rule>
    <rule>可以在 XML 结构前后添加简要的文字说明</rule>
    <rule>如果有多个同类型功能,每个功能使用独立的 XML 块</rule>
    <rule>XML 中的示例代码直接写入 example 标签内</rule>
    <rule>如果某个字段没有值,可以省略该标签或留空</rule>
  </outputFormat>
  
  <outputHint>如果查询内容不涉及上述特殊功能类型,则按照常规方式简洁输出关键信息,无需使用 XML 格式。</outputHint>
</prompt>

这样 qwen 模型也能高质量输出结构化的信息,工具输出示例,包含具体字段的名称、描述、类型、出入参数等:

<field>
  <name>freight</name>
  <type>复合类型</type>
  <description>物流模型</description>
  <arguments>
    <arg required=\"false\">
      <name>dispatchCountryCode</name>
      <type>String</type>
      <description>发货地代码</description>
    </arg>
    <arg required=\"false\">
      <name>needAlibabaGuaranteed</name>
      <type>Boolean</type>
      <description>是否返回半托管物流信息,false不返回,null 或 true均返回</description>
    </arg>
    <arg required=\"false\">
      <name>moqType</name>
      <type>String</type>
      <description>MOQ档位,实验推全,全部第一档 first</description>
    </arg>
  </arguments>
</field>

对工具响应做优化后,同样一个问题,主 Agent 的上下文窗口占用只需要花不到原来 1/10 的 token 就能解决问题了。(从 5w 下降到 4k)

上下文压缩

但即便是对工具响应进行了压缩,也还是会有窗口超限的问题,为什么 cursor 、cc 等产品好像从来没给用户暴露过这类问题呢?

GitHub 上有个 cc 的逆向工程,秘密就藏在上下文自动压缩的机制里 Claude-Code-Reverse[6]

比较靠谱的说法是,有两个触发自动压缩的时机:

1. 上下文窗口即将超限。(据传 cc 是窗口超过 82% 时触发自动压缩)
2. 新对话与历史对话毫无关联。

因为当前场景,Agent 的负担还算比较轻,所以我只选择了第一种压缩机制,就足够用了。

因为上下文压缩实际上是有损的,一旦触发压缩,Agent 就似乎忘记了之前的任务,所以 CC 在压缩的时候非常的谨慎:

所以 BFF Agent 也直接复用了这份提示词做压缩。在这个基础之上,为了使用户近期的对话被完整保留,避免一旦压缩节点就瞬间遗忘的现象,压缩时我默认会完整保留自用户近 3 轮开始对话后的消息列表。

最终的线上配置为:

{
  enabled: true, // 启用压缩
  dangerThreshold: 80, // 1-100 当上下文窗口使用超过 80% 时触发压缩
  keepRecentRounds: 3, // 保留最近 3 轮用户对话开始之后的消息不被压缩
}

消息压缩图示:

到这里,基础的优化工作就基本结束了,后续更多的优化工作,就需要采集用户的 bad case ,再不断优化提示词/工具/知识库。文章有点长,对熟练的同学来说有很多废话,但还是希望能帮助到大家~

材料链接:

Anthropic 的提示词优化技巧:https://platform.claude.com/docs/zh-CN/build-with-claude/prompt-engineering/overview

模型的自省现象:https://www.anthropic.com/research/introspection

CC 逆向工程:https://github.com/Yuyz0112/claude-code-reverse

参考链接:

[1]https://docs.langchain.com/oss/javascript/langgraph/overview

[2]https://platform.claude.com/docs/zh-CN/build-with-claude/prompt-engineering/overview

[3]https://platform.claude.com/docs/zh-CN/build-with-claude/prompt-engineering/use-xml-tags

[4]https://www.anthropic.com/research/introspection

[5]https://docs.langchain.com/oss/javascript/langgraph/use-time-travel-identify-a-checkpoint

[6]https://github.com/Yuyz0112/claude-code-reverse

状态图这种东西,理论上很美好,但实际用起来可能会发现,状态和状态之间的切换条件太多了,搞得整个图乱七八糟的。

我之前用过类似的技术做游戏AI,最后发现还不如直接写代码来得简单。不过,LangGraph 这种框架的好处是,它提供了一个结构化的思路,可以帮助我们更好地组织代码。

问:文章中使用了会话压缩来解决上下文超长的问题,这种压缩方法可能会带来哪些副作用?在实际应用中,如何平衡压缩带来的收益和损失?

答:会话压缩本质上是一种信息降维,必然会损失一部分信息。这种损失可能导致 Agent 在处理复杂任务时,缺乏足够的上下文,从而做出错误的判断或者生成不准确的回复。另外,频繁的压缩和解压缩也会增加计算成本。

为了平衡收益和损失,可以采用更精细化的压缩策略,比如:

1. 基于重要性的压缩:只压缩不重要的信息,保留关键信息和核心逻辑。
2. 渐进式压缩:随着对话的进行,逐步增加压缩比例,避免一次性丢失过多信息。
3. 可逆压缩:在需要的时候,可以恢复被压缩的信息,提供更完整的上下文。

问:文章中提到使用 XML 格式来优化提示词,为什么 XML 格式能提高模型性能?除了 XML,还有没有其他的提示词格式优化技巧?

答:用 XML 就像是给 AI 喂饭,把每一勺饭(信息)都用盘子(标签)装好,告诉它这是什么、怎么吃。AI 就不容易吃错东西,或者把饭洒了。没有 XML 的话,就像是直接把一堆饭糊 AI 脸上,它还得自己扒拉,容易懵。

其他的技巧嘛,就多了去了,比如给 AI 设定个角色(你是XXX专家),让它更有代入感;或者用“三明治”结构,把重要的指令放在开头和结尾,中间用例子或者背景知识夹住,让 AI 印象更深刻。

token 这东西啊,就像内存,用着用着就不够了。我觉得最好的办法还是精打细算,该省的地方一定要省。别一股脑把所有信息都塞给模型,要学会提炼要点,抓住关键信息。还可以限制模型的输出长度,避免模型生成过多的无用信息。最后,提醒一句,定期清理对话历史,避免上下文无限膨胀。

除了 XML,还可以尝试使用 JSON 格式,结构化地组织信息,让模型更容易理解。另外,指令的清晰度和明确性至关重要,避免使用含糊不清的词语,最好能量化你的目标。还有,可以采用思维链(Chain of Thought)的方式,引导模型逐步推理,而不是一步到位地给出答案。

问:针对提示词优化,文章提到了角色设定和XML格式,除了这两种方法,还有什么其他的提示词优化技巧可以提升Agent的性能?

嘿嘿,提示词优化这玩意儿,就像玄学,有时候灵,有时候不灵。 除了文章里说的,我再补充几点:

* KISS 原则 (Keep It Simple, Stupid): 提示词越简单越好! 别写得太复杂,模型容易懵逼。
* 使用明确的指令动词: 比如,“生成”、“总结”、“翻译”、“解释” 等等。 让模型知道你要它干什么。
* 限定输出格式: 告诉模型输出 JSON、 Markdown, 或者特定格式的文本。 方便程序解析。
* 使用同义词和 paraphrasing: 避免模型对特定词语产生过度依赖。 多用不同的表达方式,增加模型的泛化能力。
* 加入“检查清单” (Checklist): 告诉模型生成答案后,需要检查哪些内容。 比如,“检查语法错误”、“检查逻辑是否合理”、“检查是否符合事实” 等等。
* 利用负面提示 (Negative Prompting): 告诉模型“不要做什么”。 比如,“不要包含个人信息”、“不要生成有害内容” 等等。

记住,优化提示词是一个持续迭代的过程。 没有一劳永逸的 Prompt。 需要不断根据实际效果进行调整。

问:针对提示词优化,文章提到了角色设定和XML格式,除了这两种方法,还有什么其他的提示词优化技巧可以提升Agent的性能?

提示词工程(Prompt Engineering)深似海啊!除了角色扮演和XML大法,我再分享一些干货:

1. Few-shot Learning(小样本学习): 就像文章里说的,给 Agent 几个高质量的例子,胜过千言万语。让 Agent 模仿学习,事半功倍。
2. CoT (Chain of Thought,思维链) Prompting: 引导 Agent 一步一步思考,把推理过程展示出来。 尤其是在解决复杂问题时,效果拔群。 比如,你可以这样问:“为了解决这个问题,我们先…,然后…,最后…,请按这个思路给出答案”。
3. Tree-of-Thoughts (ToT): 别让Agent一条路走到黑!鼓励 Agent 探索不同的思考路径,回溯、剪枝,找到最优解。
4. Self-Consistency: 让 Agent 多生成几个答案,然后投票选出最靠谱的那个。 相当于“集体智慧”,可以有效减少幻觉。
5. RAG (Retrieval-Augmented Generation): Agent 记不住那么多知识? 没关系,外挂一个知识库! 先从知识库里检索相关信息,再喂给Agent生成答案。 相当于给 Agent 加上一个“外脑”。
6. Prompt Optimization Algorithms: 现在已经有一些算法,可以自动优化 Prompt 了! 比如,Gradient-based search, Reinforcement Learning。 简直是懒人福音!

总之,Prompt Engineering 没有最好,只有更好。 多尝试、多迭代, 才能打造出更强大的 Agent!

问:文章中提到通过监控线上脚本调用量来构建知识库,但这种方式会不会导致知识库的内容过于集中在热门脚本上,而忽略了冷门但可能很有价值的脚本? 如何平衡热门和冷门脚本在知识库中的比例?

这个问题问到了点子上! 知识库如果只盯着热门内容,很容易陷入“马太效应”,强者恒强,弱者无人问津。

要我说,得这么办:

* 打破“唯调用量”论:
* 引入多维度评估: 除了调用量, 还要考虑脚本的业务价值、技术含量、作者声望、更新频率。 就像高考一样,不能只看总分,还要看单科成绩。
* 专家评审团: 邀请领域专家, 组成评审团。 让他们来评估脚本的质量和价值。 专家的意见, 往往比数据更靠谱。
* 给冷门脚本“曝光”机会:
* 设立“冷门脚本推荐”专区: 就像博物馆里的珍品一样, 专门展示那些不为人知,但价值连城的脚本。
* Agent 主动推荐: 让 Agent 在回答问题时, 适当推荐一些相关的冷门脚本。 增加冷门脚本的曝光率。
* 举办“冷门脚本大赛”: 鼓励大家挖掘、使用冷门脚本。 就像“中国好声音”一样, 让冷门脚本也有机会一鸣惊人。
* 建立“脚本回收站”:
* 定期清理长期无人问津的脚本。 就像清理僵尸粉一样, 保持知识库的活力。
* 对于有价值但过时的脚本, 尝试进行升级改造, 让它们重焕新生。

记住,知识库不是垃圾堆, 而是生态系统。 既要保证热门内容的时效性,也要挖掘冷门内容的潜在价值。 只有这样,才能打造出一个充满活力、 持续进化的 Agent 平台!

你说的很有道理,过度依赖历史数据确实会导致 Agent 难以适应新的业务需求。所以,在提取热门脚本时,需要设置一定的时效性,比如只保留近一个月或一个季度的数据。此外,还可以结合业务发展趋势,主动添加一些新的、有潜力的脚本到知识库中。

除了压缩,还可以试试向量数据库。把历史对话和知识库都向量化,然后用相似度搜索来检索最相关的上下文,这样既能减少 token 消耗,又能保证上下文的相关性。当然,这需要选择合适的向量模型和相似度度量方法,是个技术活。

这个提问很关键啊!选模型确实得看任务。像作者这种摘要任务,对创造性和推理要求不高,那Qwen这种性价比高的模型就够用。但如果涉及到代码生成、复杂推理,那肯定得上 Claude 这种更强的。我觉得可以从这几个维度考虑:任务类型(生成、理解、推理)、性能要求(速度、准确率)、成本预算、以及模型擅长的领域。

深以为然!Qwen在这里就是个打工人的角色,干的活儿是提取信息、生成摘要,不需要太强的创造性,只需要稳定可靠。 Claude或者GPT那是领导,擅长做决策、搞创新,用在这里有点大材小用。

补充一下,选择模型还要考虑速度。摘要和检索需要快速完成,如果Claude或者GPT跑一次需要几秒钟,那用户体验就完蛋了。Qwen这种轻量级模型,速度更快,用户等待时间更短。

当然,如果未来Qwen也能进化成全能选手,那就可以把Claude或者GPT踢走了!

深有同感!大模型有时候就像一个记性不太好的“老年痴呆”,给的信息太多就容易忘事儿,或者开始瞎编。

我的经验是:

* Prompt 结构化:用固定的格式组织 Prompt,比如使用JSON、YAML等,让模型更容易理解。
* 思维链(Chain-of-Thought):引导模型逐步思考,将复杂问题分解成多个简单的步骤。
* 知识图谱增强:将知识图谱的信息融入到Prompt中,帮助模型更好地理解实体之间的关系。
* 使用 Retrieval-Augmented Generation (RAG):从外部知识库检索相关信息,添加到Prompt中,增强模型的知识。
* 模型微调(Fine-tuning):使用特定领域的数据对模型进行微调,提高模型在该领域的准确性。

感觉就像在教小朋友做题,先给他讲解相关的知识,然后一步一步地引导他思考,最后再让他自己做题。

这个问题很有意思,让我想起了以前在资源受限的环境下做开发的一些经历。

文章中提到的方案,本质上是一种状态保持在客户端的“假长连接”。

优点:

* 对FaaS平台侵入性小,不需要修改平台本身的配置。
* 实现简单,只需要利用现有的SSE和HTTP协议。

缺点:

* 客户端需要承担更多的状态维护工作,增加了客户端的复杂度。
* 每次交互都需要重新初始化AgentGraph,效率较低。

其他类似的方案:

* 利用云厂商提供的边缘计算服务,将部分计算逻辑放在离用户更近的地方,减少网络延迟。
* 使用Service Worker在浏览器端缓存数据,减少对服务器的请求。
* 对于一些非实时的任务,可以使用Web Worker在后台执行,避免阻塞主线程。

从学术的角度来看,模型产生幻觉的原因是多方面的,包括:

* 训练数据偏差:如果训练数据存在偏差,模型就容易学习到错误的知识。
* 模型容量不足:如果模型容量不足,就无法记住所有的知识,容易产生遗忘。
* 解码策略问题:不同的解码策略会对模型的输出结果产生影响。

针对这些问题,可以采取以下措施:

* 数据增强:通过数据增强技术,增加训练数据的多样性,减少数据偏差。
* 知识蒸馏:将大型模型的知识迁移到小型模型上,提高小型模型的性能。
* 对比学习:通过对比学习,让模型学习到更加鲁棒的特征。
* 正则化:通过正则化技术,防止模型过拟合。

当然,这些方法都需要一定的理论基础和实践经验。

FaaS 的优势确实不少,除了免运维和弹性伸缩,安全性也是一个重要的考量。FaaS 通常会提供一些安全特性,比如细粒度的权限控制、代码隔离等等,可以提高 Agent 的安全性。另外,FaaS 平台通常也会有完善的监控和日志系统,方便排查问题和优化性能。总的来说,选择 FaaS 可以让开发者更专注于业务逻辑,而不用过多关注底层基础设施。

从用户体验角度来说,这个方案可能会有一定的延迟。因为每次调用工具都需要重新初始化 AgentGraph,会导致响应时间变长。可以考虑优化 AgentGraph 的初始化过程,比如只加载必要的模块,或者使用缓存来提高初始化速度。或者是不是可以考虑将AgentGraph的部分状态放在前端,减少服务端的压力。

SSE 虽然简单易用,但毕竟是单向通信,如果需要更复杂的交互,可能会比较麻烦。可以考虑升级到 WebSocket,支持双向通信,这样可以实现更灵活的工具调用和数据传输。当然,WebSocket 的实现会更复杂一些,需要权衡一下。

文章里用 SSE 实现了服务端向前端的单向推送,感觉是个很巧妙的 workaround。但确实,Faas 的短连接限制是个问题。我觉得还可以考虑这几个方案:

1. WebSockets + 外部 Broker:放弃 Faas 内置方案,引入像 Redis、RabbitMQ 这样的消息队列,前端通过 WebSocket 连接 Broker,Faas 函数推送消息到 Broker,实现双向通信。不过这样会增加复杂度,引入额外的运维成本。

2. Serverless Websocket:使用云厂商提供的Serverless Websocket服务,将Websocket托管在云厂商,API网关触发Faas函数向Websocket推送消息,实现前后端双向通信。

3. 轮询: 最简单粗暴的方式!前端定时轮询 Faas 接口,看有没有新的指令或消息。虽然 low 了一点,但在某些对实时性要求不高的场景下,也能用。

4. GraphQL Subscriptions:如果 Agent 的交互是基于数据变更的,可以考虑 GraphQL Subscriptions。前端订阅数据的变化,服务端在数据变更时推送更新。这种方式更适合数据驱动的 Agent。

具体选哪个,得看你的业务场景、对实时性的要求、以及对复杂度的接受程度了。