1. 内容整体设计与思路拆解1.1 为什么需要从LLM走向Agent我在过去一年里的工作重心几乎经历了从怎么调好一个LLM到怎么让LLM真正干活的转变。最早接触大模型的时候主要做的是提示词工程、微调这几件事本质上是让模型回答得更准确。但用着用着就会发现单纯调用LLM存在一个非常明显的天花板它输出的是文本不是行动。比如说我问模型帮我查一下最近一周的天气它背出一段天气受高气压影响之类的知识却不知道怎么拿到我所在城市的真实天气数据。再比如让它处理好几个Excel文件它连打开文件的动作都做不到。说白了LLM是一个极其博学的顾问但它没有手、没有脚没有能力去操作外部世界。而Agent的出现就是为了解决语言能力和行动能力之间的落差。Agent的本质是把LLM放在一个循环里让它担当大脑的角色通过计划、推理、调用外部工具、观察结果最终完成任务。这个过程中LLM负责最核心的思考部分而真正执行具体动作的就是一层一层封装好的技能。我在实际项目里体会到如果没有Skill这一层的抽象Agent开发会迅速失控。LLM每一步都输出自由文本你很难约束它下一步该调用什么工具而Skill本质上是给LLM圈定了能力和边界让它知道遇到什么情况可以用哪个工具工具的输入输出长什么样。这个概念想通了之后整个Agent体系的搭建就顺了。1.2 Skill在Agent架构中的位置初次接触Agent的开发者往往会被各种概念搞晕。Planner、Executor、Memory、Tool、Skill、Action这几个词经常混着出现。我用一句话概括它们的关系Agent是一个有大脑、有记忆、有工具的人Skill就是这个人身上已学会的专业技能。以我目前常用的架构设计为例它大概是这样的结构底层是LLM推理引擎负责理解任务、拆解步骤、产出决策。中间层是Agent控制层负责维护状态、调用Skill、解析结果。上层是Skill层每个Skill封装一个特定的功能比如数据库查询、文件读写、网页抓取。最外层是交互层接收用户请求展示Agent最终产出。这里的Skill跟Tool工具不太一样。Tool往往是单个可调用的函数比如执行SQL、播放音频而Skill更像一个完整的能力包它可能包含多个Tool的组合还自带一套提示词约束和调用逻辑。举个例子数据库分析Skill可能包含了探查表结构执行查询生成分析报告这三个Tool级别的操作以及一整套引导LLM如何做数据分析的指令。用生活里的例子打比方Tool就像工具箱里的一把扳手Skill则是会修水管的师傅——光有一把扳手不够师傅还得知道先拧哪边、用多大力气、怎么判断是否修好。这些经验域和调用策略都写在Skill里面所以Skill比Tool高一个抽象层级更适合管理。1.3 整体设计思路三层架构与模块解耦我在做第一个真正落地的Agent时踩过一个大坑把所有逻辑都写在一个主循环里融资料查询、文件生成、邮件发送于一体。刚开始跑通的时候还挺兴奋但到后面需要新增能力时整个文件改得面目全非。后来参考了不少开源项目的设计思路才下定决心把架构拆开做成LLM核心 Agent调度 Skill扩展的三层结构。这三层各司其职LLM核心不关心业务只负责根据当前语境生成下一步动作决策Agent调度层轮询LLM输出管理任务状态把不同的Skill串成工作流Skill层自己管好自己的输入输出约束不依赖其他Skill的细节。这样拆解带来的好处很实际。第一新增一个Skill不需要动Agent主循环的代码按接口规范写好注册即可第二调试某个Skill时可以脱离整个Agent系统单独测试定位问题非常快第三多个Agent项目可以共用同一套Skill库我手头的自动化框架已经从最早的互相纠缠重构为十几个独立Skill的集合复用率明显提升。设计Skill层的时候还需要考虑一个容易被忽视的维度技能之间是否有依赖关系。比如生成周报Skill可能要依赖获取数据Skill。我习惯在Skill定义里显式声明依赖并让Agent调度层维护一个简单的依赖解析器。思路并不复杂但提前设计比运行期临场处理要省事得多。2. 核心细节解析与实操要点2.1 LLM能力边界与调用方式做Agent开发首先要对LLM本身有一个清醒的认知不然容易被它流畅的输出带偏。我在项目里对LLM的定位是一个概率化的推理引擎不是一座准确的知识库。很多人觉得模型答错是幻觉我觉得更准确的理解是它在做概率生成碰到没把握的内容会倾向于填入合理的猜测。所以当你把它接入Agent系统时核心策略是不让它直接凭记忆生成关键事实而是让它通过Skill去获取数据。比如我们做一个政策问答Agent从不让LLM直接回答政策条款而是先触发检索类Skill去资料库里查原文再让LLM基于查到的内容做总结。这个思路跟RAG检索增强生成是同一个道理只是放在Agent框架下检索这一步也被包装成了Skill。调用方式上现在的LLM一般都支持Function Calling机制也就是说你可以给模型声明一些函数的名称、参数和描述模型在需要时会输出一个结构化的函数调用请求而不是纯自然语言。这是Agent得以落地的关键技术也是Skill注册表与LLM之间沟通的协议层。我用过几个主流模型的Function Calling接口整体格式大同小异核心都是系统侧提供一个工具描述列表模型的回复里带上一个tool_calls字段里面包含了模型决定调用的函数名和参数JSON。开发者只需要解析这个JSON去执行对应的Skill再把执行结果返回给模型让模型继续推理。这套循环正是Agent最核心的感知-思考-行动模式。2.2 Agent核心循环ReAct模式与状态维护在Agent框架里最经典的执行模式是ReAct——Reasoning推理 Acting行动。它的流程是拿到目标任务后LLM先在思考阶段分析现在的情况、打算采取什么行动然后输出一个行动命令系统执行命令、拿到结果结果回传给LLM让它进入下一轮思考。如此循环直到LLM认为任务已经完成输出最终答复。我自己在实际复现ReAct模式时关注两个容易被忽视的点。第一个是状态维护。Agent循环运行几轮之后上下文里的历史输出越来越多如果全塞给LLM一方面费用会上升另一方面模型容易被淹没在冗长的日志里抓不住主线。我的做法是设置一个工作记忆缓冲区只保留最近几轮的思考痕迹、行动结果和关键中间变量超过轮次上限就做摘要压缩效果比无脑拼接全文好很多。第二个是终止条件设计。循环没有明确的结束标志模型可能一直输出行动请求形成死循环。我在循环外设置了两层保护第一层是轮次上限一般情况下任务在5次行动内完成超过12次就强制中断要求模型总结目前的进展并给出部分结论第二层是完成标志识别让LLM在认为任务已完成的回复里带上特定的结束标记程序识别到标记后就不再让模型继续行动。这两层保护看起来简单但实实在在地解决了Agent失控风险中很大的一部分。2.3 Skill的粒度与设计原则Skill设计得合理不合理直接决定一个Agent好不好用。我在反复迭代中总结出了几条原则接下来逐一细说。第一条是单一职责。一个Skill只做一件具体的事。我最早犯过的错是把查询数据库和生成报表放在同一个Skill里结果写的时候一时爽改的时候满盘都是牵扯。后来强制规定每个Skill的职责说明不超过一句话如果描述超过一句话说明这个Skill应该拆开。第二条是输入输出显式化。LLM调用Skill时输入参数需要从自由文本里提取出来映射到字段上输出则要固定成结构化格式。如果Skill的输出格式不稳定后续的Agent推理环节就很容易被带偏。我习惯给每个Skill定义清晰的输入输出Schema类似调用一个API函数的规格说明。第三条是错误返回也要结构化。Skill执行失败是常态比如网络不通、数据为空、权限不足。关键在于失败信息要能被LLM理解并指导下一步决策。我见过不少项目把Skill异常简单地打印成一行错误堆栈LLM根本看不懂。更好的做法是返回一个包含错误类型、原因描述、建议应对方式的JSON结构这样Agent就懂了怎么调整策略。下面我用一个小表格整理Skill设计时的关键考量点方便对照检查设计维度推荐做法常见错误职责范围单一职责一个Skill只做一件事把多个功能塞进一个Skill输入定义明确的参数名、类型、必填项依赖LLM猜测自由文本字段输出格式固定JSON结构包含数据与状态返回自由文本格式不可控错误处理结构化错误码 原因 建议抛异常或返回堆栈信息依赖管理显式声明依赖其他Skill直接调用其他Skill的内部函数描述文本用LLM看得懂的语言写明适用场景描述含糊模型不知道该不该调用2.4 提示词约束让LLM会开技能菜单Skill设计好之后怎么让LLM知道什么时候该用哪个Skill同样是一门学问。我的经验是不要指望LLM自然就知道Skill的适用场景得在系统提示词里给它一份技能菜单说明书。这份说明书跟普通对话时的系统提示不一样它需要更偏向决策树风格。每一个Skill的说明至少包含这几个要素Skill名称、概述、触发条件什么情况下使用它、输入参数说明、输出示例、使用限制。LLM在决策时就像翻菜谱一样先看需求匹配哪道菜再按菜谱准备材料。我还发现一个细节触发条件写得越贴近具体场景模型调用准确率越高。举例来说如果一个Skill叫计算器触发条件写当用户需要进行任何数学计算时使用效果就比计算相关功能要好得多。因为LLM判断的是用户请求和技能描述之间的语义匹配度描述越具体匹配越准确。另外我在实测中总结出一个提效技巧给Skill的启用场景配备反例。就是在描述里明确告诉模型哪些情况不应该用这个Skill。比如天气查询Skill后面加一句当用户询问的是历史气候数据而非实时天气时不要调用本Skill。有了反例约束模型的误调用率肉眼可见地下降了。3. 实操过程与核心环节实现3.1 环境准备与基础框架搭建说完了设计下面进入实操环节。我用一个数据查询与摘要生成的Agent为例完整演示从零搭建一个带Skill的Agent。这个案例虽然简单但它涵盖了Agent开发的基本链路后面做更复杂的项目都是在这个框架上做加法。我用的技术栈是Python配上目前比较通用的LangChain生态。模型部分我建议用支持Function Calling的中等规模模型进行开发和测试成本低且调试方便。基础环境安装就不详细说了需要重点说明的是框架选择思路LangChain这类框架最大的价值不是它自带的那些高级链路而是它把工具注册—模型调用—结果回传这个循环做好了省去你自己去写一遍底层的函数调用解析。安装好环境后我习惯先把最朴素的裸循环跑通而不是一上来就套框架的高级API。裸循环的代码大致是这个样子from openai import OpenAI client OpenAI() messages [ {role: system, content: 你是数据处理助手负责按流程调用工具完成用户任务。} ] tools [ { type: function, function: { name: query_database, description: 在本地数据库执行SQL查询并返回结果, parameters: { type: object, properties: { sql: {type: string, description: 要执行的SQL语句} }, required: [sql] } } }, { type: function, function: { name: generate_summary, description: 对传入的数据生成一段简洁的中文摘要, parameters: { type: object, properties: { data: {type: string, description: 需要总结的原始数据} }, required: [data] } } } ] def call_llm(messages): resp client.chat.completions.create( modelyour-model-name, messagesmessages, toolstools, tool_choiceauto ) return resp.choices[0].message这一步的意义在于验证两件事第一模型能不能正确理解工具定义第二循环解析函数调用有没有bug。框架的封装再漂亮底层原理也是这套。彻底搞懂这个循环之后再用框架的高级API就顺手多了。3.2 编写第一个Skill从注册到执行接下来是一个完整的Skill实现。我定义两个Skillquery_database和generate_summary。按照之前的设计原则每个Skill对应一个解析函数、一个执行函数、一个结构化输出。import json import sqlite3 def execute_query_database(sql: str) - dict: Skill: query_database 职责: 在本地测试库中执行SQL查询 try: conn sqlite3.connect(test.db) cursor conn.cursor() cursor.execute(sql) rows cursor.fetchall() columns [desc[0] for desc in cursor.description] conn.close() return { success: True, columns: columns, rows: rows, row_count: len(rows) } except Exception as e: return { success: False, error_type: type(e).__name__, error_message: str(e), suggestion: 请检查SQL语法或表名是否存在 } def execute_generate_summary(data: str) - dict: Skill: generate_summary 职责: 对输入数据生成摘要 summary_prompt f请用不超过三句话概括以下数据的关键信息\n{data} resp call_llm([ {role: user, content: summary_prompt} ]) return { success: True, summary: resp.content }Skill执行函数和工具函数是两个概念这个区别我刚开始没搞清。工具函数是纯函数指做什么操作Skill执行函数负责把LLM传过来的参数转换成工具函数能用的格式再把结果翻译回LLM看得懂的结构化消息。执行完Skill之后最关键的步骤是把结果回注给LLM。这一步我见过很多人做得不够好直接把原始结果塞回去就完事导致LLM面对一堆冗长的数据库输出不知所措。正确的做法是在回注结果时加上简短的前情提要def send_tool_result_to_llm(tool_call_id, result_dict): summary f工具执行完成。状态{成功 if result_dict[success] else 失败}。 if result_dict[success]: summary f共返回{result_dict[row_count]}行数据。 else: summary f错误原因为{result_dict[error_message]}。 return { role: tool, tool_call_id: tool_call_id, content: json.dumps(result_dict, ensure_asciiFalse, defaultstr) }这里我额外加了一个人类可读的前置摘要字段放在结构化JSON之前。实测下来这能显著减少LLM读取长结果的出错概率类似给一个纯技术报告贴上执行摘要先让模型知道大概发生了什么事。3.3 Agent主循环与完整运行示例下面把上面的模块串起来写成Agent主循环。我用一个简化版本重点展示循环控制逻辑def run_agent(user_query: str, max_rounds: int 5): messages [ {role: system, content: 你是数据查询助手。当用户需要查询数据时先调用query_database再使用generate_summary总结结果。}, {role: user, content: user_query} ] skill_executors { query_database: execute_query_database, generate_summary: execute_generate_summary } for round_idx in range(max_rounds): assistant_msg call_llm(messages) if not assistant_msg.tool_calls: return assistant_msg.content messages.append(assistant_msg) for tool_call in assistant_msg.tool_calls: func_name tool_call.function.name args json.loads(tool_call.function.arguments) if func_name not in skill_executors: error_result { success: False, error_type: UnknownSkillError, error_message: f未知技能{func_name}, suggestion: 请只使用提供的技能 } messages.append(send_tool_result_to_llm(tool_call.id, error_result)) continue result skill_executors[func_name](**args) messages.append(send_tool_result_to_llm(tool_call.id, result)) return 已达到最大执行轮次任务可能未完整完成。我用一个实际的执行场景来演示效果。假设用户问帮我查一下销售表中各地区的订单总数并简要总结。执行过程大概是第1轮LLM输出tool_call调用query_database参数SQL是SELECT region, COUNT(*) FROM orders GROUP BY region系统解析参数、执行数据库查询返回成功结果第2轮LLM结合查询结果判断还需要调用generate_summary来总结数据系统执行摘要生成返回总结文本第3轮LLM判断任务完成直接输出最终答复不再产生tool_call。这段日志清晰地展示了ReAct模式在三轮之内解决一个组合任务的完整链路。一个值得注意的点是LLM在要不要再调用一次工具这件事上判断通常比较保守——倾向于多调用而不是早结束。所以在设计任务时尽量把目标拆到刚好需要调用特定Skill的颗粒度不要让模型频繁尝试无谓操作。3.4 技能注册表与管理随着Skill数量增加管理它们就成了一门新的功课。我搞了十几个Skill之后开始用一个注册表来统一管理。class SkillRegistry: def __init__(self): self._skills {} self._tool_defs [] def register(self, skill): self._skills[skill.name] skill self._tool_defs.append(skill.to_tool_definition()) def get_tool_definitions(self): return self._tool_defs def execute(self, skill_name: str, **kwargs): skill self._skills.get(skill_name) if not skill: raise UnknownSkillError(skill_name) return skill.execute(**kwargs)注册表带来的好处不只是集中管理更重要的是它统一生成了LLM所需的tools列表。每次新增Skill只要写一个类并register前端不用做任何改动。当我需要临时禁用某个Skill时只需从注册表移除它的tool定义模型自然就不知道有这个技能存在了。注册表还有一个进阶用法——给Skill分区。比如分成数据类文件类网络类每个区对应不同的安全权限。数据类Skill不允许操作非查询语句文件类Skill不允许删除目录这类约束用注册表做统一控制比在各个Skill内部各自判断要可靠得多。3.5 进阶让Skill具备自我描述能力做到这里Agent已经能跑通查询-分析-总结流程了。但还有一个值得深挖的方向让Skill具备自我描述能力。这个思路来自我对Agent自我认知的思考——如果Skill只有静态描述模型对它的理解是有限的反过来如果Skill能动态回应模型的询问Agent对任务的理解就会深得多。实现方式也不复杂。在每个Skill模块中加入一个describe_capability方法返回这个Skill在当前数据环境下能做什么、不能做什么。比如query_database这个Skill可以在描述里动态带上当前数据库中存在以下数据表orders, customers, products模型据此就能生成更贴合实际数据的SQL查询。我实际测试过一个场景Agent被问到帮我看看客户购买力分布如果没有动态描述模型可能会尝试查询一个不存在的字段而动态描述让模型先通过describe_capability获取到表结构生成查询时准确率大幅提高。这个模式可以看作是RAG在Skill层的一个变体——不是检索文档片段而是检索技能能力清单。4. 常见问题与排查技巧实录4.1 Function Calling解析与参数匹配问题在所有Agent开发的问题里Function Calling的解析出错是出现频率最高的。我在初期几乎每天都会遇到这样的情况LLM输出的tool_call参数是合法JSON但字段类型跟我的预期不一致。比如SQL参数本该是字符串模型传了一个数组数据类型本该是浮点数模型做成了字符串。排查这类问题我总结了一套流程。第一步打印完整的原始响应不要自己解析后再打印——很多时候问题就出在解析过程中比如用了不兼容的JSON解析库。第二步对比模型响应的参数和tools定义里的参数看是否因为描述不够清晰导致模型误判类型。第三步最关键检查是否把推理和执行混在了一起。模型有时会在工具参数里填入一个打算如何做而不是要求程序做什么。比如让它执行SQL查询参数却传了SELECT * FROM orders WHERE...再加一段命令解释文本。这是模型对工具语义理解不足的表现解决办法不是在代码里做各种容错而是回到Skill的描述文本把参数定义写得更精确。我后来在参数schema里加了一个example字段——给每个参数附上一个明确的示例值。效果立竿见影参数类型错误率下降了一截。虽然这个字段大部分API规范里不是必填的但强烈建议加上。4.2 模型坚持不调用Skill的问题有时候问题反过来明明Skill定义得很清楚LLM却偏偏不调用它直接基于自己的知识回答。这种情况最常见于用户的提问方式和Skill描述之间的语义距离比较远时。比如我给知识库检索Skill写了当用户询问事实性问题时使用但用户问的是那个规定的有效期到什么时候模型可能觉得这是在打听时间而不是需要查文档。针对这种情况我从两个方向做了优化。第一个方向是补全Skill的别名在描述里列出尽量多的近义表达。比如查询类Skill的描述里加上查找、获取、搜索、定位、确认信息。第二个方向则是调整系统提示词里的任务拆解指引明确告诉模型对于可能涉及事实/数据的问题请先调用检索类技能核实再回答。这两件事配合起来模型乱答的比例下降得非常明显。还有一个容易被忽略的原因模型版本更新后行为会变。同一个运行配置上周还表现正常这周模型升级后忽然不按套路调用工具了。所以生产环境里给模型版本上锁是必要的不要用最新版这样的模糊配置避免行为漂移带来不可控的变化。4.3 上下文爆炸与记忆管理Agent循环跑得多了上下文里会积累大量tool返回内容。很多时候一个数据库查询返回了几百行数据全部塞进消息列表几轮下来模型就记不住初始目标了。我在一次调试中发现模型在长上下文里竟然忘记了用户最初的问题开始顺着工具结果自由发挥。解决这个问题的核心思路是分层记忆。我把记忆分成两层短期工作记忆用于存放当前任务正在处理的数据长期记忆用于存放任务目标、已完成步骤、关键结论。每次循环时短期记忆只保留最新的工具结果摘要长期记忆则持续更新。回传给模型时把两层记忆合并成一段精炼的状态描述而不是堆砌原始文本。用摘要压缩长文本的时候我习惯保留关键数字和名字因为LLM在信息压缩中很容易丢失具体数值。我在摘要Prompt里特别强调保留所有数字和专有名词可用省略句但不要模糊化关键数据。这算是一个小细节但能救回来好几次任务失败。4.4 技能执行的安全与边界控制最后聊一个跟技术关系不太大但同样重要的话题执行安全。Agent一旦接入真实业务系统它的操作就有真实的副作用。我在沙箱环境测试和真实环境部署之间梳理了两条底线。第一高风险操作必须人工确认。比如删除数据、发送对外邮件、支付转账这类权重大、后果不可逆的执行在Agent流程设计时就要插入人类确认节点。LLM可以生成操作指令但真正提交前必须经人批准。这不算什么高级技术但没了这一步Agent上线后你随时可能被它的自主发挥背后捅一刀。第二给每个Skill划定执行边界。前面提到的注册表分区就是一种实现方式。更细化的手段包括数据库查询禁止非SELECT语句、文件操作限制在指定目录、网络请求限定域名白名单。这些约束写死在Skill内部或注册表层面不能被模型通过提示词绕过。我的原则是宁可让Agent显得笨——不会做的操作直接说自己做不了也不要让它去做可能造成伤害的操作。我在测试中试着让Agent尝试突破边界比如通过提示词注入让它试图读取系统文件好在一层一层的权限校验全部挡了回去。这套安全机制花的时间不少但价值是被验证过的。生产环境里你永远不知道用户会说出什么来。4.5 常见问题速查表为了方便排查我把高频问题整理成一张速查表遇到问题先对照一下现象常见原因排查思路实践解决方案工具参数类型错误参数描述不够具体打印原始模型响应在参数schema中添加example字段模型拒绝调用技能Skill描述与任务语义距离远检查触发条件描述补充近义触发词增加反例上下文过长导致遗忘工具结果重复塞入对话统计消息体长度增长实现分层记忆与摘要压缩工具结果解析报错LLM返回格式不合预期打印异常JSON片段增加解析容错并重试一次一条SQL导致死循环循环终止条件缺失检查最大轮次是否失效强制轮次上限 完成标志识别技能之间的副作用Skill耦合度过高检查注册表依赖关系拆分为单一职责Skill顺便说一个我在真实项目里遇到的教训型问题某次Agent一直重复调用同一个查询技能原因是数据库返回的结果里包含一个错误值导致模型认为查询没成功于是一遍遍重试。最后定位到是数据本身有问题而不是代码逻辑问题。只能说模型的错觉也会传染给Agent。5. 一些实操心得与后续扩展方向写到这里这篇学习笔记的核心内容差不多讲完了。最后分享几点我在大量试错之后沉淀下来的个人体会算是一些没那么正式的心得。第一件事别迷信框架。很多人一提到Agent开发就想用现成的自动化框架比如各类Auto-X框架。我试过一些发现它们的问题在于通用与场景不匹配。框架帮你内置了很多通用的思考方式但真实业务往往需要你精确控制调用过程。与其花大力气适配框架的抽象不如自己写一个不到两百行的循环再把Skill做精。到后面你回过头看那些框架的核心也就是这个循环。第二件事把Skill当成产品来做。我之前总把Skill当工具函数看待能用就行。后来慢慢意识到一个优秀的Skill应该像一个设计良好的接口它有清晰的用途、合理的错误提示、可预测的行为。如果团队里多个人合作开发Agent把Skill文档写好比把代码写漂亮更重要。我在自己的项目里给每个Skill维护了一个Markdown文档内容甚至包括我什么时候不应该被调用这种元信息。第三件事关于迭代思路。Agent开发跟传统软件开发很不一样的地方在于它没有完全正确一说只有行为更稳定。所以测试方式也要改变。我习惯准备一组固定的测试用例每次调整提示词或Skill定义后跑一遍看行为模式有没有偏离。这个方法虽然土但在追求稳定性的场景下非常实用。后续这个项目还可以往几个方向扩展。一是加入更完善的记忆机制让跨会话的长期记忆真正落地——不是简单地存对话而是把每次任务的结论沉淀成可以被检索的知识。二是引入更复杂的任务规划能力比如让Agent自己拆解子任务并分配给多个子Agent执行每个子Agent持有各自擅长的Skill。三是做更细粒度的可观测性把每个决策步骤的推理依据和工具调用参数记录下来方便离线复盘模型行为。这些方向我还在持续探索中等有一些阶段性结果了再来更新这份笔记。
阅读完成 · 觉得有帮助?