很多人第一次接触 Agent 和 RAG,第一反应是这些名词背后肯定藏着很深的算法,得先把 Transformer、注意力机制这些啃完才敢动手。我在实际带新人的时候发现,解释这两个东西最好的方式不是从论文开始,而是先记住两句话:Agent 是让大模型学会“办事”,RAG 是让大模型学会“查资料”。只要抓住这个方向,剩下的都是工程细节。
这篇文章的目标读者就是标题里说的“小白程序员”。你可能只写过增删改查接口,可能只听过提示工程但没系统学过机器学习,都没关系。我会从大模型解决不了的几个问题讲起,分别拆解 Agent 和 RAG 的核心流程,再给你两份可以直接复制运行的极简代码。最后还会把我自己在实际项目中踩过的坑、排查过的诡异问题整理成速查表,方便你收藏后照着用。
1. 为什么必须学 Agent 和 RAG:大模型没那么全能
1.1 大模型天生不擅长什么
很多人以为大模型只要参数够大、训练数据够多,就什么都知道。实际使用下来你会发现,它至少有四件事天生做不好:不知道最新信息,不知道你的私有数据,不能替你操作系统,也不能保证长任务中途不出错。
打个比方。你去问一个刚入职的实习生“今天会议室有没有空”,他如果没有看系统、没有打电话,只能凭印象乱猜。大模型也是这样,它的训练数据截止于某个时间点,之后发生的事它完全不知道。哪怕你问的是今天天气,它也会一本正经地编一个可能不存在的答案。训练语料里没有录入的公司制度、产品文档、客户资料,对它来说同样是盲区。
另外,大模型本身只是跑在服务器上的一个程序,它没有“手”,不能直接修改数据库、不能发邮件、不能调用内部接口。你让它“把第 5 号工单状态改成处理中”,它只能告诉你该怎么改,而不是真正去改。至于长任务更明显,前面刚算完三步,后面可能就把自己的结论忘了,逻辑断裂、重复动作都是常事。
这四点就是 Agent 和 RAG 存在的根本原因:模型没有的资料,用 RAG 临时塞给它;模型做不了的动作,由 Agent 安排工具去执行。
1.2 Agent:把模型从“聊天机器人”变成“办事员”
Agent 不是一个神秘的新模型,它是一套让模型反复决策的程序。想象一下你让一个实习生去处理客户投诉,他需要先查订单系统,发现数据异常,再问主管怎么办,然后根据答复去更新状态。Agent 的逻辑完全一样,只不过“实习生”换成大模型,“查系统、问主管”换成外部工具调用。
这套循环最关键的地方在于:模型并不真的执行操作,它只是“决定要调用哪个工具、参数是什么”。真正执行的是你写的函数。比如模型说“调用查询订单接口,参数是 ORDER-2025-001”,你的代码就去请求接口,拿到结果后再把它还给模型。模型看到结果后,再决定下一步是继续查库存,还是直接生成最终回复。
所以 Agent 至少包含四个部分:工具定义、决策入口、循环控制、状态整理。工具定义告诉模型“你有什么本事”,决策入口负责把用户问题交给模型,循环控制决定“要不要继续调模型”,状态整理则是把多轮工具结果压缩成模型能看懂的上下文。理解这四个部分,比只跑通一个 Demo 重要得多。
1.3 RAG:让模型在回答前先“开卷”
如果你只是想让模型回答私有知识问题,不想花大价钱微调,RAG 是目前性价比最高的方案。RAG 的全称是检索增强生成,工作流其实很朴素:用户提问后,先从你自己的文档库里找出最相关的几段资料,再把这几个片段连同问题一起交给模型,让模型基于资料作答。
这个思路好比考试时允许带资料。模型不用把整本手册背下来,它只需要在拿到题目后翻到正确的那一页,照着重点答题。好处也很直观:知识更新只需要替换文档索引,不用重新训练;回答有据可查,模型瞎编的概率大幅降低;对计算资源的要求也远低于微调。
我在自己做的问答 Demo 里,最初只是把一份几十页的产品手册硬塞给模型,结果上下文直接超长,费用高、回答还乱。后来改成 RAG,只检索出与问题相关的两三段,模型输出立刻稳定,调用成本也降了一个数量级。对小白来说,RAG 是你上手的第一个必学技能。
2. 先别急着写代码:把这两个概念彻底搞清楚
2.1 RAG 不是向量数据库,而是一套流程
市面上很多教程会让人去安装某个向量数据库,跑一个搜索脚本,就说“RAG 完成了”。这是一个非常典型的误解。向量数据库只是 RAG 流程中用来存向量、做相似度检索的仓库,它既不是充分条件,也不是 RAG 的全部。
真正的 RAG 流程至少包含:文档加载、文本切片、向量化、索引构建、查询改写、检索、重排、答案生成。每一步都有独立的设计空间。切片切得多大,会影响检索召回准不准;向量模型选哪一个,会影响语义相似度判断;检索到的片段怎么拼进提示词,会影响最终答案质量。这些都是 RAG 的一部分。
所以当你听到别人说“我们用向量数据库搞定了 RAG”,不要觉得他专业。你可以追问一句:检索前有没有做查询改写?切片策略是什么?Top-K 是多少?回答有没有做事实校验?这些问题一问出来,对方就知道你不是完全零基础。对小白来说,前期甚至不需要引入数据库,用 Python 的列表加余弦相似度就能跑通全流程。
2.2 Agent 不是简单的“工具调用”
类似地,Agent 被误读为“模型支持 Function Call”。从 API 层面看,模型返回参数确实叫 tools 或 function_call,但完整的 Agent 需要多轮迭代。模型第一次返回“调用查天气接口”,你的程序执行后,需要把结果再次返回给模型,模型再生成“今天的天气是晴天”这个最终回答。如果只做一次调用,其实只是单次 Tool Calling,还算不上完整的 Agent。
区分标准很简单:有没有围绕目标进行“决策—执行—观察—再决策”的循环。比如让模型查一个城市的天气后再推荐穿搭,它可能需要先调用天气工具,看到结果后再生成建议;如果气温低于十度,它可能还想再查一下风力。这中间的任何一步都可能需要重新调用模型。没有循环,就没有 Agent。
另外,想清楚一个问题:Agent 的“智能”来自哪里?来自模型的推理能力,也来自你给的工具描述。描述清楚,模型知道什么时候该用、参数怎么填;描述模糊,模型就像手里有工具但看不懂说明书的人,会频繁乱调或者干脆不用。
2.3 两者合体:先查资料再办事,还是边办事边查资料
实际业务里,Agent 和 RAG 经常不是独立工作的。最典型的一个结合场景是智能客服工单系统:用户说“我的订单已经三天没发货,帮我查一下物流并发起催单”。这时 Agent 需要拆解任务:第一步,调用订单查询工具,拿到订单状态;第二步,发现状态异常,触发 RAG 查询内部关于物流异常的处理规则;第三步,根据规则调用催单工具;第四步,汇总结果回复用户。
在这个过程里,RAG 变成了 Agent 工具箱里的一个特殊工具。模型先判断“需要对内部知识库进行检索”,然后调用这个检索工具的接口,返回相关片段,再进入下一步。可以理解为:RAG 解决“知识从哪里来”,Agent 解决“任务怎么推进”。两者结合,就是当下大多数企业级大模型应用的标准架构。
你不需要一开始就掌握这种复杂组合,但心里要有这个整体图景。这样你在做最初 Demo 的时候,才不会被后续业务扩展打乱节奏。
3. 手写第一个 Agent:20 分钟跑通工具调用
3.1 最小环境准备与模型访问封装
开始写代码之前,先确认你的 Python 版本在 3.9 以上,并安装好相关依赖。不同模型提供方的接口差异不小,但只要是兼容格式的 SDK,用法基本一致。我这里用一个虚构的 llm_sdk 示例来演示,你只需要把密钥、地址和模型名换成自己实际在用的即可。
先封装一个 chat 函数,避免后面重复写请求逻辑:
import json from llm_sdk import Client client = Client( base_url="https://your-endpoint.example.com/v1", api_key="your-api-key" ) def chat(messages, tools=None): kwargs = {"model": "model-name", "messages": messages} if tools: kwargs["tools"] = tools resp = client.chat.completions.create(**kwargs) return resp.choices[0].message这里有一个小白经常误踩的点:不要把 api_key 直接硬编码在最终要提交的代码里,Demo 可以临时用,生产环境必须通过环境变量或密钥管理服务读取。否则代码一旦被推到公开仓库,密钥就等于送人了。
3.2 定义两个最常用的工具:天气查询与计算器
为了让第一个 Agent 结构清晰,我选了“查天气”和“算算术”这两个工具。一个代表外部 API,一个代表本地计算函数。模型并不能真的执行它们,只会根据工具描述生成调用意图。
先说 Python 函数:
def get_weather(city: str) -> str: # 这里应该请求真实天气服务,Demo 里直接返回假数据 return f"{city}:晴,23摄氏度,微风" def calculate(expression: str) -> str: try: result = eval(expression) except Exception as e: return f"计算出错:{e}" return str(result)注意:在实际项目里不要用eval执行用户随便传来的表达式,这里只是演示简单场景,生产环境建议用安全解析器或只允许固定运算。
然后定义工具描述,这是指导模型的关键。每个工具需要参数名、类型、描述、是否必填:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的天气情况,参数 city 是城市名称", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如北京、上海" } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "计算简单的数学表达式,比如加法、乘法", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,例如 3 + 4 * 2" } }, "required": ["expression"] } } } ]工具描述里最重要的不是篇幅,而是参数口径。如果模型生成的参数总是出现字段名拼写错误,先检查你的描述是否足够明确。例如“城市名”三个字听起来简单,但如果用户说的是“上海的天”,模型可能犹豫是 city 还是 location,描述里重提一次“期望字段为 city”效果会好很多。
3.3 实现主循环:模型决定、执行工具、观察结果、再决定
完整的 Agent 主循环其实只有几行,关键在于循环条件。我在本地实现后,整个循环逻辑可以浓缩成下面这段:
def run_agent(user_input: str, max_iterations: int = 5): messages = [{"role": "user", "content": user_input}] function_map = { "get_weather": get_weather, "calculate": calculate, } for step in range(max_iterations): msg = chat(messages, tools=tools) if not msg.tool_calls: return msg.content tool_messages = [] for tool_call in msg.tool_calls: fn_name = tool_call.function.name fn_args = json.loads(tool_call.function.arguments) result = function_map[fn_name](**fn_args) tool_messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) messages.append(msg) messages.extend(tool_messages) return "已达到最大迭代次数,未完成最终回答"执行流程不复杂:第一步把用户问题发给模型;第二步检查回复里有没有 tool_calls;没有就说明模型已经给出最终答案,直接结束;如果有,就逐个执行函数,并记录每个结果;第三步把模型回复和工具结果追加到 messages 列表,重新调用 chat;第四步重复直到模型不再要求调用工具。刚才这段代码里我给 max_iterations 设了默认值 5,防止模型陷入无限循环。
这里有个角色归属必须注意:每条工具结果消息的 role 必须是 “tool”,而且必须带上 tool_call_id,否则服务端会认为消息链断裂,报错“工具调用 ID 不匹配”。项目实践中我看到大批新手在这里折腾半小时,最后发现只是把工具返回消息挂错了角色。
3.4 第一个 Agent 必然会踩的几个坑
第一,模型给出的参数类型和你的函数签名不匹配。模型是按 JSON Schema 来生成参数的,但偶尔也会把数字写成字符串,甚至把单个字段生成成数组。例如计算器工具期望 expression 是字符串,模型可能生成一个列表。稳妥做法是在函数内部做一层参数转换和类型校验,而不是信任模型的每一步输出。你可以在调用前打印 arguments,肉眼核对一下,时间长了就能摸清模型在这个工具上的“脾气”。
第二,工具函数本身报错会导致循环卡死。如果 get_weather 在请求真实服务时超时,异常会直接中断程序。正确做法是让工具函数永远返回一个文本结果,哪怕是“接口超时”,这样模型还能基于这个结果调整策略。不要在不该 throw 的地方 throw,Agent 主循环最怕未知异常把整个流程打断。
第三,无限循环和 token 浪费。模型有时会反复调用同一个工具,特别是在工具描述不清晰的时候。除了限制 max_iterations,你还可以记录调用次数,一旦超过阈值就让模型“直接生成一个基于已有信息的答复”,这样用户体验也不会太差。不要小看这一步,真实环境中一个失控循环可能会让单次请求的 token 消耗暴涨几十倍。
第四,安全边界。给 Agent 的工具权限越大,风险越大。如果工具里有“执行 shell 命令”或“删除数据库记录”这类高危能力,至少要加一个确认流程。新手很容易为了演示效果把权限放得很宽,导致模型误操作。我的习惯是高危工具一律做成“先返回待确认操作给用户,用户说确认才真正执行”。
4. 手写第一个 RAG:从文档到回答的完整链路
4.1 先把流程跑通:加载、切片、向量化、检索、生成
RAG 的代码量其实也很小。我习惯让新人在一个单文件脚本里做完五个步骤,先不去管复杂框架。这里用一个简化示例:假设你已经把文档读进来并做了切片,然后调用 embedding 服务得到向量。
如果你完全不知道 embedding 是什么,可以把它理解为“把文本变成一个数字数组”,语义相近的文本,数字数组在空间中的距离也相近。这个数组就是“向量”。后续检索本质上是在这些向量里做最近邻搜索。
一段极简的检索代码如下:
import numpy as np def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) def retrieve(query_vector, document_vectors, top_k=3): scores = [ cosine_similarity(query_vector, doc_vector) for doc_vector in document_vectors ] top_indices = np.argsort(scores)[-top_k:][::-1] return [documents[i] for i in top_indices]这段代码只做了一件最重要的事:把问题向量和每个文档向量做余弦相似度比较,取得分最高的 Top-K 个文档片段。在数据量不超一万条、对性能要求不高的情况下,这种方法完全够用。等数据多了再考虑加向量数据库或者逼近搜索索引,千万不要一上来就上重武器。
4.2 切片策略与向量模型选型
切片是所有 RAG 项目里最容易被低估的环节。切得太大会把多个主题混在一起,模型检索时会得到大量噪音;切得太小会让片段失去上下文,模型看着半句话根本无法作答。我实测比较多的小白项目,效果不好的第一原因不是向量模型不行,而是切片太随意。
第一,优先按语义边界切片。如果文档有章节标题,可以先用标题切割;如果只是纯文本,可以使用固定长度加重叠窗口。例如每段 500 个字符,重叠 100 个字符,这样既能控制长度,又能避免把完整句子从中间切断导致信息丢失。
第二,向量模型选型的核心不是“越贵越好”。在中文场景里,要看你手头文档的领域:技术手册和日常对话差异很大。最靠谱的方法是准备二十个典型问题,分别用候选模型跑一轮检索,看哪个召回更符合预期。别急着选付费模型,先用本地开源的极小模型验证流程。
第三,特殊格式的文档必须提前处理。PDF、扫描件要经过文字识别转成纯文本,否则向量模型只能看到乱码。很多新人以为是自己向量化技术不对,其实是源文档里的信息根本没被正确提取。
4.3 检索到资料之后,怎么组装 Prompt 才不白搭
很多新手做完检索,直接把几段原文大量塞进提示词,结果模型回答还是不如预期。原因在于提示词里没有说清楚“资料用在哪、查不到怎么办”。我建议的最小模板是这样:
请阅读下面的参考资料,然后回答用户问题。 参考资料: {context} 用户问题:{question} 要求: 1. 只能基于参考资料回答。 2. 如果参考资料中没有答案,请直接回答“资料中没有相关信息”。 3. 不要编造参考来源没有的内容。第 2 条尤其重要。模型有很强的补全倾向,面对缺失信息也会强行生成一个看起来合理的答案。你必须在提示词里明确允许它“认怂”。我在多个项目里测试过,加上这句话之后,回答的无中生有率显著下降。
组装时还应该保留来源标题或编号,例如在 context 里标注“[1] 来自产品手册:...”这样后续如果需要做引用溯源,模型能直接给出出处。即使当前只是 Demo,也建议养成这个习惯。
4.4 把五个步骤串成最短可用代码
把上面的内容串起来,一个最小 RAG 主流程大概是这样的:
# 假设已经准备好 documents 列表 # 假设 embedding 接口返回向量列表 query = "产品支持离线缓存吗?" query_vec = embed([query])[0] matched = retrieve(query_vec, doc_vecs, top_k=3) context = "\n".join([f"[{i+1}] {doc}" for i, doc in enumerate(matched)]) final_messages = [ {"role": "system", "content": "你是一个严谨的客服助手。"}, {"role": "user", "content": template.format(context=context, question=query)} ] answer = chat(final_messages) print(answer)整个过程没有引入任何大型框架,只用列表、循环、相似度计算就能看到效果。对小白来说,先把这条链路跑通,再去了解那些现成的 Agent 和 RAG 框架会容易十倍。因为你已经知道每一步在干什么,再去看框架时面对的就不是黑盒,而是一堆你认识的组件的自动组合。
5. 实战问题与排查技巧实录
5.1 检索结果明明有相关句子,模型还是答错
这是我最常遇到的问题。现象是:RAG 检索出的 Top-K 文本里确实包含答案,但模型最终回答却南辕北辙。逐层排查后,我发现主要原因有三个。
一是检索到的片段被拼接后顺序混乱,模型没有建立上下文。建议在 context 里按原始位置重新排序,而不是按相似度从高到低排。二是提示词里没有强制要求“优先使用引用片段原文”,模型可能把片段当作旁证,自己重新推理。三是最新版模型对长上下文的注意力分配偶尔会失效,片段夹在太多无关内容里,重要信息被淹没。解决办法也简单:降低 Top-K,只保留最相关的两到三个片段。
如果你还想进一步定位,可以把检索到的片段单独发给模型,只问“上述哪些片段蕴含答案”,这样能区分是检索问题还是生成问题。
5.2 Agent 工具调用时反复出现参数错误
我在调试一个订单查询 Agent 时,模型总是把订单号参数传成数组,而接口希望是普通字符串。第一次遇到时我以为模型坏了,直到检查工具描述才发现问题:我在描述里写了“订单号可能有多个,请用数组”。模型于是严格遵守了描述。这提醒我:工具描述中的每个词都会被放大理解,含义必须精确,不要用模糊的复数表达。
另一个原因是函数内部签名和 JSON Schema 不一致。建议把函数的入参定义、JSON Schema、实际接口字段三者放在同一个文件里,命名保持一一对应。如果你改了函数签名,却忘了改 Schema,模型生成的参数执行时必然报错。给我的自查清单:先打印模型生成的 arguments,再手动调用工具函数,最后才把结果写回消息链。三步下来,90% 的参数错误都能定位。
5.3 Agent 不停循环,最后把 token 烧完了
新手最心疼的就是这幕:跑一个看似简单的任务,模型连续调了七次工具,对话费用蹭蹭上涨。其中最常见的原因是前面工具返回错误后,模型没有足够信息修正,于是继续尝试同一个动作。好比一个人站在钥匙面前却反复按门铃,因为系统没有告诉他“钥匙不对”之外还能怎么办。
应对方案有三个。第一,设置硬性轮数上限,超出即停止;第二,工具返回里追加一条“你可以尝试以下替代方案”的提示,例如“天气接口超时,请询问用户是否改用昨日数据”,把模型往合理路径上引;第三,记录已调用过的工具组合,当同一组合重复出现两次时,由外层程序强制返回当前信息。这些技巧不复杂,但能把失控概率下降一个量级。
5.4 成本、延迟与安全速查表
对于刚刚上手的项目,我整理了一张问题排查速查表,可以直接照着判断:
| 表现 | 可能原因 | 优先排查方向 |
|---|---|---|
| 检索返回很多垃圾内容 | 切片过大或没切分 | 增加重叠、按语义边界切片 |
| 回答内容来自模型脑补 | 提示词未限制 | 加上“资料没有就回答不知道” |
| 工具参数频繁报错 | Schema 描述不清晰 | 精简描述,统一字段命名 |
| 循环次数异常 | 工具结果无引导信息 | 返回结果中加“下一步建议” |
| 调用成本飙升 | Top-K 太大或轮数太多 | 降 K、截断工具返回、设上限 |
| 敏感操作被模型执行 | 工具权限过大 | 增加人工确认、限制高危工具 |
这张表不是标准答案,但可以当作你排查问题的起点。每次遇到奇怪现象,先记录当时的输入、输出和中间步骤,再拿表对照,比自己瞎调参高效得多。
6. 从小 Demo 走向工程化的一点经验
6.1 把 Agent 工具做成一键注册模块
当你手里有二十个工具,再在函数里写一个硬编码的 function_map 就会很痛苦。工程化第一步是把“工具注册”抽象成一个装饰器,把函数、Schema、权限、超时都绑定在一起。代码大概长这个样子:
@register_tool(name="get_weather", description="查询天气") def get_weather(city: str) -> str: ...注册表会自动收集描述并生成 tools 列表,运行时通过名称查表调用。这个设计改动不大,但维护成本低很多,新增工具只需要写一个新函数,不用动主循环。等你哪天要加权限控制,也可以在装饰器里统一加。
6.2 从单轮 RAG 到多轮问答和混合检索
Demo 常见的单轮问答只能解决一次提问,但真实业务里用户会追问“那如果延迟到三天呢?”要实现追问,必须把前面几轮对话也带进检索上下文。一种做法是把历史对话整理成“用户上一问/模型上一答”的摘要,再与当前问题拼接后检索。另一种做法是让 Agent 在规划阶段决定是否需要改写查询词,比如用户说“那它支持的接口有哪些呢”,这里的“它”需要解析回上文的固件版本。
混合检索也是工程化的必经之路。向量检索擅长语义理解,但关键词检索在代码、型号、人名面前往往更准。我常用的组合是先并行跑向量检索和关键词检索,再用一个轻量重排程序把两个结果合并排序。刚开始你不需要做到这一步,但理解这个方向,能让你在面对检索效果瓶颈时知道从哪下手。
6.3 最后说点我的真实感受
Agent 和 RAG 之所以让很多小白程序员觉得困难,是因为市面上教程要么只讲概念让人停在理论,要么直接丢一堆框架代码让人照抄,唯独缺少“从最朴素原理开始一步步实现”的路径。我自己也是反复折腾了几个项目才彻底想明白:这些技术本质上都不复杂,复杂的是边界条件。先跑通一个能读文档、能查天气、能做计算的最简系统,再慢慢加需求,你会发现所谓“大模型开发”其实和写普通业务代码没有本质区别,只是把控制权从程序员手里部分交给了模型的判断。
“收藏必备”这个标题里说的收藏,我更希望你不只是把文章放进收藏夹,而是今天就打开终端,把第 3 节和第 4 节的代码原样跑一遍。跑通一次,你对 Agent 和 RAG 的理解会超过看十篇原理文章。如果之后你在跑通流程时遇到奇奇怪怪的报错,可以回到第 5 节的速查表,先按表格排查,再根据日志逐层定位。技术这东西,动手后的半小时,比阅读很多天的收获都大。