![脚本运行结果 (2)](https://hackmd.io/_uploads/SkzLDYYc1l.png) Ai Agent算是这个阶段最为火热的叙事了,但是实际上来说Agent其实并没有那么复杂,但是他实现的功能可以非常强大。AI为我们创造了无数的神奇黑盒,将大量的逻辑代码变成了一个一个小小的API Call,极大的提升了我们开发的效率。 但是,不是自己的东西,始终是用起来不顺手,与其在这么多Ai Agent中徘徊不定,不如自己搞懂Ai Agent的底层逻辑,自己构建一个Agent Framework,岂不美哉? # Agent是什么 实际上,LLM(大语言模型)的面纱已经不再神秘,伟大的东方鲶鱼deepseek让整个链路变的如此透明,我们可以轻松的完成finetune(微调),MoE(混合专家模型),Distillation(蒸馏),但是实际上他们都是基于已有的大模型进行某些方面的训练或者功能强化,并不需要巨量的人力,海量的数据进行训练。 而Agent做的事情更简单,大部分Agent的核心功能只是单纯的对LLM进行API调用,然后将返回的数据进行筛选,组合,连续调用等,实际上都可以认为是对AI的Prompt工程 — 俗称提示词工程。 在编程思想中,我们可以认为AI Agent是一个外部的包,我们不了解他的内部构造,不知道他如何实现,但是他提供给我们了一个调用模版。比如: ```jsx /** * 获取主流货币价格 * @param name 代币名称 * @returns nubmer 价格 */ function get_price(name:string): number; ``` 你可以认为agent就是一个这样的黑盒函数,你不知道他到底是怎么实现的,但是你可以通过他指定的调用方式,传入指定的参数,得到需要的结果。这和你和ai对话的过程是一样的,在提示词中,你需要指定ai的回答方式,返回格式,要求,例子等,以求获得更加有效,符合要求的回答。抽象一下,就等于构造了上面的函数。 ## Agent的基础 说到这里,你其实应该已经明白了,ai是需要大量提示词的,他和人一样,越是丰富的上下文,回答的结果越是令人满意。想象一下,你在一个纯粹的空白空间中,失去了五感,你只能控制身体进行行动,此时有一个声音在你的脑海中想起:快跑!你应该怎么跑?往前,往后,还是要跳跃,是否有台阶?此时,就算是人也没有办法得到最佳答案,随着越来越多的提示词插入,比如:你现在在水中,水底距离你只有20厘米,最近的岸边在右后方10米,等等,越多的信息,你可以给出越是精确的答案,你的生活经历和学习的知识会判断各种情况下要做出什么选择,但是这些额外的信息将会辅助你选择更好的答案。 AI也是如此,你对他提供的信息越多,得到的结果就越好,AI和人类非常相似,虽然学过,但是也会忘记,因此,充足的上下文和知识片段,也可以提升他的能力。 如果你理解了以上部分,那么你已经明白了大部分Agent的工作原理,实际上就是提示词工程,通过组合不同的需求,生成不同的提示词,这就是Agent的最基础的部分。 ## Function Calling 但是,主动提供数据,并不是最完美的方案。因为,在某些情况下,你需要提供的资料会非常多,AI的上下文局限性导致超大上下文的表现并不一定好,越多的不相关内容只会降低他的能力,同样的,人们也越来越需要让AI自己选择有利于他答题的内容,并且越来越希望他可以在外界获取信息来补充能力,或者直接对外界进行操控,完成自动化控制等功能。 于是 2023年6月 OpenAI提供了AI Agent最核心的功能,[Function Calling](https://openai.com/index/function-calling-and-other-api-updates/)。从此事开始,大量的出色的Agent框架开始涌现,比如langchain,dify等。此时,Agent最核心的部分,与外界进行联系的部分已经构建完成,通过这个功能,我们可以按需给AI提供数据,或者让AI调用一些我们设定的函数,让他与真实世界互动。此时,命运的齿轮开始转动。 当然,Agent远不止这一点点小的进展,还有各种Memory,RAG等优化功能,这些内容我们将会在后续章节中讲到。系列文章会带大家一步一步的创造自己强大的Ai Agent,不过,我们先实现最基础的内容。 # 实现一个最基础的AI Agent 说是AI Agent,实际上是一个加上Function Calling功能的ChatGPT,别担心,我们现在只是第一章的内容,让我们Ai Agent之路简单点不好吗? 因此,我已经准备好了一个初始模版。当然,这个模版并不会包含关于任何AI的内容,他只是一个纯粹的UI模版,毕竟我们是来做AI Agent的,而不是来学习前端的。 ## 初始化 模版地址:[https://github.com/monad-developers/easy-agent](https://github.com/monad-developers/easy-agent) ```jsx git clone https://github.com/monad-developers/EasyAgent.git cd EasyAgent git switch stater pnpm install ``` 接着,我们可以执行 `pnpm dev` 查看默认的模版。初始化的模版下只提供了模块的前后端和UI组件。你应该可以看到这个页面。 ![image](https://hackmd.io/_uploads/S1lWDFYc1l.png) 此时我们成功运行了我们的前端和后端程序。 > 为什么不直接使用nextjs自带的route功能,一定要分离前后端为两个程序? 原因很简单,nextjs自带的server功能目前只能是作为serverless函数调用,无法支持后台常驻型的程序运行,并且,如果我们使用vercel的custom server之后,不但会导致项目无法在vercel上部署,同样的也无法享受到完整的nextjs服务。所以我们不如简单的放弃使用nextjs。转为使用vite驱动的react程序,在速度上也会有非常大的提升。 > ## 准备后端程序 后端的构建极为简单,伴随着Vercel AI SDK提供的强大功能,我们可以轻松构建好一个后端处理程序。 构建之前,我们先安装好AI SDK。 ```jsx # 项目根目录下执行 pnpm add ai @openrouter/ai-sdk-provider --filter @easyagent/backend ``` 我们通过pnpm workspace的功能,可以无需进入指定文件夹就能给指定的app进行依赖安装。此处安装了ai(vercel ai sdk), @openrouter/ai-sdk-provider(openrouter的provider实现)。 我们后续的代码将会使用openrouter作为provider,你完全可以选择不同的provider,比如openai,anthropic,google ai,grok,deepseek等等,但是在一般情况下,我更为推荐openrouter,因为openrouter将所有的提供商进行聚合,我们只需要注册一个openrouter,出一分钱,就可以随意的切换不同的模型,极大的减少了开发量。 你可以到 [https://openrouter.ai/](https://openrouter.ai/) 注册。 ## 编写核心逻辑 因为ai sdk提供了非常方便的,封装好的包,于是我们只需要不到30行代码即可完成逻辑功能。 核心逻辑其实就是将用户传来的消息,通过调用大模型的API,执行,并且获取返回。是的第一步就是如此简单。 修改文件 `apps/backend/src/index.ts` 。 ```jsx // ... import import { stream } from 'hono/streaming'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createDataStream, streamText } from 'ai'; // ... app.use('*', cors()); const openrouter = createOpenRouter({ apiKey: c.get('model.api_key'), }) let model = openrouter.chat(c.get('model.name')) app.post('/stream-data', async c => { const { messages } = await c.req.json() // immediately start streaming the response const dataStream = createDataStream({ execute: async dataStreamWriter => { const result = streamText({ model, messages, }); result.mergeIntoDataStream(dataStreamWriter); }, onError: error => { // Error messages are masked by default for security reasons. // If you want to expose the error message to the client, you can do so here: return error instanceof Error ? error.message : String(error); }, }); // Mark the response as a v1 data stream: c.header('X-Vercel-AI-Data-Stream', 'v1'); c.header('Content-Type', 'text/plain; charset=utf-8'); return stream(c, stream => stream.pipe(dataStream.pipeThrough(new TextEncoderStream())), ); }); const port = process.env.PORT || "3000"; //... 剩余代码 ``` 上面这一大段代码其实动不重要,因为核心代码只有几句话,其他的都是为了支持stream功能的代码 核心代码如下 ```jsx // ... // 创建openrouter的provider const openrouter = createOpenRouter({ apiKey: c.get('model.api_key'), }) // 获取模型 let model = openrouter.chat(c.get('model.name')) app.post('/stream-data', async c => { const { messages } = await c.req.json() // 从请求中获取用户请求message // ... // 调用vercel的streamText,请求LLM的API,并且以流式返回 const result = streamText({ model, messages, }); // ... }); //... 剩余代码 ``` 接下来我们就可以测试了,我们先填好openrouter的apikey和模型name,然后测试一下。 创建 `apps/backend/config/local.toml` 文件,按照以下格式填写 ```toml [model] name = "google/gemini-2.0-flash-001" # 这里你可以任意选择llm,但是不要选择推理模型 api_key = "你的api key" ``` 然后执行 `pnpm dev` 就可以测试了。 打开一个新的窗口,执行 ```jsx curl --location 'http://localhost:3000/stream-data' \ --header 'Content-Type: application/json' \ --data '{ "messages": [ { "role": "system", "content": "You are a helpful assistant." }, { "role": "user", "content": "Hello!Please use 100 words tell me about Bitcoin" } ] }' ``` 这是使用curl进行流式请求,如果没有意外的话,你得到的结果应该是: ![image 1](https://hackmd.io/_uploads/ryLzDtFqke.png) 此时,你已经完成了第一步,调用大模型,是的,就是如此朴实无华。 ## 准备前端程序 首先安装一下依赖。 ```jsx pnpm add @ai-sdk/ui-utils @ai-sdk/react --filter @easyagent/frontend ``` ui-utils是一些小工具,@ai-sdk/react 则是给react使用的前端工具包。 修改 `apps/frontend/src/App.tsx` ```jsx // 1. 将 const messages:UIMessage[] = [ { id: "1", role: "user", content: "Hello!",parts:[] }, { id: "2", role: "assistant", content: "Hello! How can I assist you today?", parts:[] }, ]; // 改成 const { messages, input, handleInputChange, handleSubmit } = useChat({ maxSteps: 5, api: `${API_BASE}/stream-data`, }); // =============== // // 2. 将 <form className="flex flex-col w-full"> <TextareaAutosize className="resize-none border-none focus:outline-none shadow-none mb-6" placeholder="Type your message..." /> <Button type="submit">Send</Button> </form> // 改成 <form onSubmit={handleSubmit} className="flex flex-col w-full"> <TextareaAutosize className="resize-none border-none focus:outline-none shadow-none mb-6" placeholder="Type your message..." value={input} onChange={handleInputChange} /> <Button type="submit">Send</Button> </form> ``` 好了,前端也改完了,依旧简单的致命。 然后执行 `pnpm dev` ,如果你之前执行了就不用重复执行。 结果: ![image 2](https://hackmd.io/_uploads/HkgXwKtq1l.png) # 让AI访问外部世界 单纯做一个ChatBot肯定不是我们想要的,这完全不能称之为Agent,因此我们需要为AI开眼,让他可以访问到外部世界的数据,比如AI,我们想要AI介绍BTC的同时给出价格,那么就需要为他提供Function Calling,我们也可以称之为Tool。不过,在这个框架中,我希望叫他为Agent,这是一个设计概念,后续的章节中会讲述理由。 ## 编写一个Pyth价格获取器 Pyth价格获取更是简单,根据 [Pyth Docs](https://docs.pyth.network/price-feeds/fetch-price-updates) ,我们只需要一个RPC,请求就可以获取到某个币的价格。比如: ```bash # 执行 curl -X 'GET' \ 'https://hermes.pyth.network/v2/updates/price/latest?ids%5B%5D=0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace' ``` 结果类似如下: ```json {"binary":{"encoding":"hex","data":["xxx"]},"parsed":[{"id":"ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace","price":{"price":"272427580943","conf":"136803809","expo":-8,"publish_time":1739594267},"ema_price":{"price":"272862600000","conf":"135345888","expo":-8,"publish_time":1739594267},"metadata":{"slot":198146252,"proof_available_time":1739594268,"prev_publish_time":1739594267}}]} ``` 其中,price表示无精度价格,而expo表示实际价格要将小数点往右移动八位。为什么要选用pyth做例子?稍后你就知道了。 现在,让我们进入 `packages/agents/pyth-fetcher/index.ts` 这个文件,这是我准备好的一个Agent模版,在类中,我们需要实现一个函数用于获取价格。 ```tsx export class PythFetcherAgent extends BaseAgent { public name = "pyth-fetcher" @action("Fetch price data from Pyth", GetPricesParametersSchema) public async get_prices(parameters: GetPricesParameters): Promise<GetPricesResult> { const params = new URLSearchParams() for (const symbol of parameters.symbols) { params.append("ids[]", ids.find(id => id.assetSymbol === symbol)!.priceFeedId) } const res = await fetch("https://hermes.pyth.network/v2/updates/price/latest?" + params.toString()) const data: PythPricesResult = await res.json() // 处理一下数据,AI的计算能力很弱,所以最好是帮他计算好需要的内容。 const result = data.parsed.map(item => ({ name: ids.find(id => id.priceFeedId.includes(item.id))!.assetSymbol, price: adjustPrice(item.price.price, item.price.expo) })) return result; } } function adjustPrice(price: string, expo: number) { if (expo >= 0) return price + '0'.repeat(expo); const insertAt = price.length + expo; return price.slice(0, insertAt) + '.' + price.slice(insertAt); } ``` 上面代码主要逻辑为,通过传递symbol,找到对应的id,然后通过id请求pyth的数据,最后直接返回结果。 你可能好奇 @action 这个装饰类是干什么的,其实他只是一个小小的标记功能,让后续传入vercel ai sdk更加方便。感兴趣可以简单看看实现。 不过实际上效果应该是等同于 ```tsx // tool为ai sdk提供的函数 tool({ description:"Fetch price data from Pyth", // GetPricesParametersSchema 的内容如下 parameters: z.object({ symbols: z.array(z.enum(names)), }, // 函数内容如下 execute: (parameters:GetPricesParametersSchema)=>{ const params = new URLSearchParams() for (const symbol of parameters.symbols) { params.append("ids[]", ids.find(id => id.assetSymbol === symbol)!.priceFeedId) } const res = await fetch("https://hermes.pyth.network/v2/updates/price/latest?" + params.toString()) const data: PythPricesResult = await res.json() // 处理一下数据,AI的计算能力很弱,所以最好是帮他计算好需要的内容。 const result = data.parsed.map(item => ({ name: ids.find(id => id.priceFeedId.includes(item.id))!.assetSymbol, price: adjustPrice(item.price.price, item.price.expo) })) return result; }, }); ``` 此时我们回到 `apps/backend/src/index.ts` ,添加一下我们提供的工具类。 ```tsx //... 省略代码 let model = openrouter.chat(c.get('model.name')) const pythAgent = new PythFetcherAgent() //... 省略 const result = streamText({ model, messages, tools:{ ...pythAgent.toTools() } }); ``` 完成✅! # 看看我们的Agent 回到我们前端界面,输入 告诉我当前btc的价格。返回如下 ![image 3](https://hackmd.io/_uploads/SkFmvYFcyg.png) 此时我们询问AI时,他已经可以正常的触发调用Tool的请求,并且根据下方网络请求的日记我们也可以看到,此时我们返回了我们自定义的数据给AI。 有趣的是,我们只需要定义函数的调用方式和参数即可,而我们返回的内容,LLM会自动尝试识别和解析。 ## 打开黑盒,添加Trace功能 对于简单的请求,其实我们也不需要什么trace功能,但是对于一个非常负责并且有着多重调用的Agent来说,Trace就是一个非常重要的功能,如图。 ![image 4](https://hackmd.io/_uploads/BJzVPFK5ke.png) Trace下,你的对话请求,费用,调用生成,模型类型等等各种信息都会记录,在多Agent的环境下也可以轻松的还原当时的场景,并且还支持你根据当时请求的内容回放,修改等操作。因此Trace实际上是一个对Agent来说非常重要的功能。我们最后一部分则是集成这个Trace。 首先需要注册一个Langfuse账号:[https://cloud.langfuse.com/](https://cloud.langfuse.com/),并且在里面创建组织和项目,这里就跳过这些不重要的内容。到最后是,我们只需要记住两个东西,publicKey和secretKey。 然后创建文件 `apps/backend/src/trace.ts` ,输入以下内容 ```tsx import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; import { NodeSDK } from "@opentelemetry/sdk-node"; import c from "config"; import { LangfuseExporter } from "langfuse-vercel"; export function initTrace() { let sdk: NodeSDK | undefined; if (c.get('langfuse.enabled')) { sdk = new NodeSDK({ traceExporter: new LangfuseExporter( { secretKey: c.get('langfuse.secretKey'), publicKey: c.get('langfuse.publicKey'), baseUrl: c.get('langfuse.baseUrl'), } ), instrumentations: [getNodeAutoInstrumentations()], }); sdk.start(); console.log('langfuse started'); } const shutdownHandler = (signal: string) => { sdk?.shutdown(); process.exit(0); }; process.on('SIGINT', () => shutdownHandler('SIGINT')); process.on('SIGTERM', () => shutdownHandler('SIGTERM')) } ``` 别忘了安装依赖 ```bash pnpm add @opentelemetry langfuse-vercel --filter @easyagent/backend ``` 然后我们在 `apps/backend/src/index.ts` 中调用 `initTrace()` ```tsx // ...省略 import { initTrace } from './trace'; import c = require('config'); config(); const app = new Hono() app.use('*', cors()); // 添加 initTrace(); const openrouter = createOpenRouter({ apiKey: c.get('model.api_key'), }) ``` 然后修改config文件, `apps/backend/config/local.toml` 添加内容 ```tsx [langfuse] enabled = true secretKey = "填写" publicKey = "填写" baseUrl = "https://cloud.langfuse.com" ``` 然后在启动我们的Agent,询问任意内容。此时你应该就可以在Langfuse中看到你刚刚的请求的内容和详细的调用过程了。 # 总结 实际上,AI Agent的大部分核心实现都来自于OpenAI提出的Function Calling功能,并且在后续的LLM模型中,都支持了这种功能(推理模型除外),因此,我们可以认为是一个行业标准,并且轻松的去使用它,而不需要大费周章的自己实现这种复杂的功能。 本文主要讲述的Agent最基础的ChatBot的功能实现,你也可以认为是一个小型的Agent发展和进化的过程,在后续,我们将会为当前Agent提供Memory和RAG等其他功能,并且实现多Agent的协作能力,最终的目标是实现一个可以持久运行,自我思考,自我判断,并且能与人交互的智能AI Agent。