资讯详情

资讯详情

从API调用到AI Agent雏形:多轮对话记忆与Function Calling实战

说实话“AI应用开发学习”这个系列写到现在我最大的感受是网上教程太多讲“怎么调一个接口”的遍地都是但讲“从接口到能用的小东西”中间那段路的人太少。前两天的学习计划我主要花在基础准备上把开发环境、HTTP调用、鉴权方式这些骨架过了一遍。到了第三天我决定不再对着文档空转而是给自己定一个明确目标做一个有记忆、能调用外部工具的Agent雏形把这几天学到的东西真正串起来。如果你也卡在“会调用大模型接口但不知道怎么往下走”的阶段这篇Day03的记录应该能让你少走一些弯路。我尽量把每一步的选择理由、踩坑点、以及最终的代码长什么样都写清楚方便你直接照着复现。1. 第三天的目标拆解为什么我执意要做一个“会干活”的Agent1.1 我给自己定的三个目标学习最忌讳的就是漫无目的地刷文档。所以我给Day03定了三个非常具体的目标第一把单次请求扩展成多轮对话让Agent能“记住”前面说过的话第二实现一次真实的工具调用让模型不只是输出文字而是真的去查数据、算结果第三把这个Agent封装成一个HTTP服务为后面接前端、接自动化流程做准备。这三个目标刚好对应三条主线上下文管理、Function Calling函数调用、服务化部署。哪怕后面的学习再深入这三条线也始终是AI应用开发的地基。1.2 为什么坚持自己用requests调API而不是直接上框架在我列学习计划的时候很多人都建议我直接学LangChain或者Spring AI那一套。我也认真对比过最后决定这一阶段不引入框架原因有几个第一个原因是“搞清楚黑箱”。框架确实快点几下就能连上模型但如果基座模型换了一个、接口格式变了一点你很容易被框架的错误信息搞得一头雾水。自己用裸HTTP方式写一遍请求体的每一个字段、响应里每一个字段的含义都会非常清楚。第二个原因是“版本迭代太快”。这类框架的版本变动非常大很多概念这个版本有、下个版本就改了。对于学习阶段来说直接用最稳定的HTTP接口反而是最不容易过时的方案。第三个原因是“调试成本低”。如果请求失败了我可以直接拿原始请求和原始响应来看不需要剥开框架的层层封装。对于一个三天的新手来说这种可控性是安全感的重要来源。等到这个雏形跑通了再考虑用框架重构那时候你对框架的理解也会完全不一样。2. 环境准备与第一次模型调用最容易被忽略的接入细节2.1 账号、密钥与接口兼容性Day03开始前我需要一个能用的模型服务。选型时我关注两件事一是要支持OpenAI兼容的/chat/completions接口因为这套格式最通用后面切换其他服务商基本不用大改代码二是要有比较清晰的文档和错误提示。这里有个容易忽略的点很多服务商的“兼容OpenAI”并不是100%兼容。有些在messages里要求额外的字段有些对max_tokens的取值范围限制不同有些则不支持stream参数。所以我建议第一次接入时先完整读一遍目标服务的接口文档不要想当然。密钥管理也是新手容易翻车的地方。千万不要把Key硬编码在代码里还要提交到公开仓库我见过太多这种事故了。正确做法是放到环境变量里或者放到.env文件并加入.gitignore。2.2 最小可用代码先跑通再谈优化我们的第一个目标很简单发一条消息拿到回复打印出来。我用的是Python环境配合requests库来写。代码大概长这样import os import requests import json API_URL os.getenv(API_URL, https://your-endpoint.example.com/v1/chat/completions) API_KEY os.getenv(API_KEY, your-api-key) def chat_once(user_text: str) - str: payload { model: demo-chat-model, messages: [ {role: system, content: 你是一个严谨的开发者助手回答问题时尽量给出可执行的方案。}, {role: user, content: user_text} ], temperature: 0.7, max_tokens: 1024, stream: False } headers { Authorization: fBearer {API_KEY}, Content-Type: application/json } resp requests.post(API_URL, headersheaders, jsonpayload, timeout60) resp.raise_for_status() data resp.json() return data[choices][0][message][content] if __name__ __main__: print(chat_once(用一句话解释什么是AI Agent))这段代码里我想额外说几点timeout60一定要设。默认情况下requests可能一直等下去如果模型响应慢或者网络有波动你的程序会卡死在那里。resp.raise_for_status()不能省。它能帮你快速发现鉴权失败、参数错误等常见问题省去不少排查时间。max_tokens设得太小会截断答案设得太大则费用会变高。1024对大多数问答场景足够。2.3 流式输出给用户“打字机”体验第一次跑通之后我的第二个改动是把stream设为True。流式输出的意义不只是“看起来酷”它在实际体验上有一个明显的好处用户不用干等整段回复生成完。大模型生成一段200字的回答通常需要几秒到十几秒如果不流式用户只能看到转圈体验非常差。流式输出的响应体不是一次性JSON而是多行以data:开头的内容最后以data: [DONE]结束。处理逻辑要稍作调整def chat_stream(user_text: str): payload { model: demo-chat-model, messages: [ {role: system, content: 你是一个严谨的开发者助手。}, {role: user, content: user_text} ], temperature: 0.7, stream: True } headers { Authorization: fBearer {API_KEY}, Content-Type: application/json } with requests.post(API_URL, headersheaders, jsonpayload, streamTrue, timeout60) as resp: resp.raise_for_status() for line in resp.iter_lines(): if not line: continue line_text line.decode(utf-8) if line_text.startswith(data: ): data_str line_text[6:] if data_str [DONE]: break chunk json.loads(data_str) delta chunk[choices][0][delta].get(content, ) print(delta, end, flushTrue) if __name__ __main__: chat_stream(来一段关于Python装饰器的简短介绍)这里踩过的一个坑是流式模式下响应里的字段不是message而是delta。新手很容易套用非流式的解析方式结果打印出一堆空内容。另外每一行都要按字节解码不能直接用默认字符串拼接。3. 上下文怎么“记住”messages列表的设计逻辑3.1 简单拼接不够角色信息同样重要到了这一步单次调用已经不是问题了。但AI应用开发的核心价值在于多轮交互那么如何让模型“记住”上下文就成了绕不开的问题。原理上模型本身是无状态的。它之所以看起来有记忆是因为我们在每次请求时把完整的对话历史都塞进了messages字段。所以只要你愿意理论上可以把无限长的历史都带上只要不超过模型的上下文窗口。但“带上历史”和“带好历史”是两码事。我做了一个简单的实验不加任何处理直接把所有历史消息拼接进下一次请求结果发现两个问题第一随着对话轮数变多Token消耗越来越大响应速度也越来越慢第二早期对话里的噪音会持续干扰后续回复模型容易“跑偏”。这其实就是为什么消息列表里要有role区分的原因。system负责设定人设和规则user代表用户输入assistant代表模型历史回复。如果让模型区分“哪些是规则”“哪些是闲聊”它在长对话中的稳定性会好很多。3.2 截断策略我只保留最近的十轮为了解决Token膨胀的问题我参考了常见做法写了一个简单但有效的滑动窗口缓存只保留最近N条消息超过部分直接丢弃。from collections import deque MAX_HISTORY 10 # 保留最近10轮每轮含user和assistant history deque(maxlenMAX_HISTORY * 2) def build_messages(user_text: str) - list: messages [ {role: system, content: 你是我的AI开发助手。} ] for role_text, content in history: messages.append({role: role_text, content: content}) messages.append({role: user, content: user_text}) return messages def append_history(role_text: str, content: str): history.append((role_text, content))这里deque的maxlen非常合适它会在超出长度时自动弹出最旧的数据省掉了手动pop(0)。为什么保留最近十轮因为更早的对话大概率已经不重要了而且max_tokens是有限的与其让历史占空间不如把空间留给真正需要思考的最后一轮。3.3 一个容易踩坑的细节assistant回复必须原样回传另一件特别容易踩坑的事是多轮对话时你不仅要保存每次用户的输入还要保存模型的回复并且在下一轮把模型的回复以assistant角色原样放回消息列表。我第一次实现时只存了用户输入结果第二轮的效果和第一轮几乎没差别因为模型看到的历史是不完整的。只有在用户和AI的消息交替成对时模型才能理解对话的节奏。所以我的建议是每轮请求成功后立刻把user和assistant的消息都追加到历史里。如果请求失败那就只追加用户消息等下次成功后再补上回复避免把失败时的空响应当成历史。4. 让Agent学会“使用工具”一次真实的Function Calling实践4.1 为什么不直接写死逻辑而是让模型来决定很多人在做AI应用时会走入一个误区所有逻辑都在提示词里规定死比如“如果用户问天气你就返回天气信息”。这种做法在简单场景下能用但一旦分支变多提示词会变得异常复杂而且模型经常不按套路出牌动不动就“自由发挥”。更好的思路是你不需要让模型自己掌握数据只需要让它学会决定“该调用哪个工具”。这就是Function Calling函数调用的核心思想。模型在生成回复之前可以输出一个结构化的“工具调用请求”你的程序收到之后去执行真正的工具再把工具结果返回给模型让它基于结果生成最终回答。类比一下模型像是前台客服它自己不会查库但它知道哪些问题该转到哪个部门。你只需要给它一本“部门电话簿”也就是工具定义它就能把用户的请求准确转接过去。4.2 工具定义与调用流程一个简化版的“查天气”案例我选择了一个非常经典的案例天气查询。为了让演示不依赖外部服务我直接在工具函数里内置了一份假天气数据。下面是核心代码import json def get_weather(city: str) - str: # 演示用假数据实际项目请接入真实天气API weather_map { 深圳: {温度: 28, 天气: 多云, 湿度: 70}, 北京: {温度: 22, 天气: 晴, 湿度: 40}, 上海: {温度: 26, 天气: 小雨, 湿度: 80}, } data weather_map.get(city, {温度: 25, 天气: 未知, 湿度: 50}) return json.dumps(data, ensure_asciiFalse) TOOLS [ { type: function, function: { name: get_weather, description: 查询指定城市当前的天气情况, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如深圳 } }, required: [city] } } } ]定义工具时我要特别提醒几点description字段一定要写清楚。模型就是靠这个描述来理解“这个函数是干嘛的”描述越具体调用准确率越高。参数用JSON Schema格式定义类型、必填项都要标全否则模型可能猜错参数名。一次可以定义多个工具但初学者建议先从一个开始跑通流程后再叠加。当用户问“深圳天气怎么样”时整个调用流程是这样的把用户输入、历史消息、工具定义一起发给模型模型返回一个tool_calls指令里面包含函数名和参数程序自行执行get_weather(深圳)拿到天气结果再把用户问题、模型请求、工具结果全部打包发给模型模型基于工具结果生成最终的自然语言回答。下面这段代码展示了第2步到第5步我会把“第一次模型响应”和“携带工具结果的第二次响应”合在一起呈现def ask_with_tool(user_text: str) - str: messages [ {role: system, content: 你是一个贴心的生活助手。}, {role: user, content: user_text} ] first_resp call_model(messages, toolsTOOLS) choice first_resp[choices][0] # 判断是否有工具调用请求 if choice.get(finish_reason) tool_calls: tool_call choice[message][tool_calls][0] func_name tool_call[function][name] arguments json.loads(tool_call[function][arguments]) print(f[调用工具] {func_name} - {arguments}) if func_name get_weather: result get_weather(arguments[city]) messages.append(choice[message]) messages.append({ role: tool, tool_call_id: tool_call[id], content: result }) second_resp call_model(messages, toolsTOOLS) return second_resp[choices][0][message][content] return choice[message][content]需要注意的是第4步传回去的消息列表里必须包含模型第一次回复中message对象的完整内容包括tool_calls一条新增加的角色为tool的消息且tool_call_id必须和模型请求里的id完全一致。如果tool_call_id对不上模型会报错如果漏传了模型第一次的message模型会不知道你自己调用工具这回事。这两点是我实际调试时最容易出错的地方代码里我特意把步骤写全了。4.3 实测效果与后续思路用上面的代码实测输入“帮我看看北京现在适合穿什么衣服”模型会先调用get_weather(北京)拿到天气数据后再结合温度、天气给出穿衣建议。整个流程跑通的那一刻我真的觉得“AI应用开发”和“调接口”是两件完全不同的事情前者是让模型真正参与到决策链条里。下一步我打算扩展工具集比如加入“生成SQL并执行”的工具把文本输入直接转成查询结果这是我看相关热词里“AI生成SQL”比较火的一点后面单独写一篇记录。5. 把Agent封装成服务部署前的最后几道工序5.1 用FastAPI快速包一层HTTP接口写好的Agent不能每次都在终端里跑我需要给它一个可以被外部访问的入口。我用FastAPI来做这件事因为它自带接口文档、参数校验而且性能足够用。最简单的封装是这样的from fastapi import FastAPI, HTTPException, Request from pydantic import BaseModel app FastAPI() class ChatRequest(BaseModel): message: str session_id: str default class ChatResponse(BaseModel): reply: str app.get(/health) def health_check(): return {status: ok} app.post(/chat, response_modelChatResponse) def chat(req: ChatRequest): try: reply ask_with_tool(req.message) return ChatResponse(replyreply) except Exception as e: raise HTTPException(status_code500, detailstr(e))启动服务用一行命令uvicorn main:app --host 0.0.0.0 --port 8000为什么把session_id单独拎出来因为真正的生产环境里每个用户都应该有独立的对话历史不能所有用户共享一个上下文。这里可以先按session_id作为历史的key为后续接入Redis或者数据库缓存留好接口。5.2 成本控制与超时处理服务化之后就不得不考虑成本和稳定性了。我给这一环节整理了一个自查清单项目建议超时时间接口层设置30秒超时避免连接长时间占用历史长度按场景设置轮数上限我这边是10轮并发限制用信号量限制同时进行的模型请求数量模型选择简单任务用轻量模型复杂推理用更强模型Token监控每次响应记录usage信息便于月底核算成本另外流式输出在前端效果更好但服务和网络环境都要支持长连接。如果部署在某种默认禁止长时间空闲连接的环境中流式会非常容易断。这时候要么改为普通响应要么让前端用轮询。5.3 内容安全与合规自查这一步我认为是AI应用开发里最不应该跳过的一环。模型生成的内容是不可控的如果直接把它暴露给用户很容易出现不合适的输出。我的做法是在模型调用前后分别加入两个环节输入侧对用户请求做基础过滤明显违法违规的内容直接拒绝处理输出侧对模型回复做关键词检测和长度控制发现风险内容就走兜底话术。同时模型的system提示词里要明确约束不能生成违法、有害、涉及个人隐私的信息。这不是为了“限制模型能力”而是为了让你做出来的应用能真正站得住脚。提示这块建议在开发初期就放进正式流程。等服务真正上线再补成本完全不同。6. 第三天学习结束后的下一步计划Day03结束前我重新检查了一遍今天产出的代码一个带滑动窗口记忆的聊天模块、一个实现了Function Calling的工具调用流程、一个用FastAPI封装好的HTTP服务接口。说实话代码量不算大但“从无到有”把一个AI Agent雏形跑起来的过程比之前单纯看文档有效率得多。接下来我的计划是把工具集从单一的天气查询扩展到两个以上测试模型的“选择能力”用Redis替换内存里的历史队列让多进程、多实例部署时上下文不丢失加上鉴权逻辑至少做到接口不能被随便刷尝试接入一个简单的向量库思路让Agent能回答知识库类的问题。如果你也在自学AI应用开发我建议不要只做“看教程”这件事哪怕你的目标只是跑通一个小Demo也一定要动手让代码跑起来。很多东西你只有自己报错了、去查了才能真正记住。最后分享一个小技巧每次跑通一个功能我会顺手把请求日志和响应日志存下来。后面遇到类似问题翻日志往往比重新搜索资料更快。这个习惯让我少踩了很多坑希望对你有用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →