1. 三次造轮子的起因:一个客服工单分类 Agent 的演进史
去年底我接了个内部需求,给客服团队做一个工单自动分类的小工具。需求本身不复杂:读取工单文本,判断它属于退款、物流、账号、产品咨询中的哪一类,然后打上标签写回系统。团队里没人做过 AI 相关的功能,我算是被赶鸭子上架。
第一版我选了最直接的路子——直接调 API。理由很简单:需求小、时间紧,没必要为了一个分类功能引入一整套框架。写了个 Python 脚本,把工单内容拼进 prompt,调一次模型,解析返回结果,完事。整个脚本不到 80 行,跑起来也确实能用。
但问题很快来了。客服团队希望这个工具能接入他们日常用的编辑器,让坐席在写工单的时候就能看到分类建议,而不是切到另一个网页。于是有了第二版:上 AgentCore,把分类逻辑封装成一个可编排的 Agent 服务。再后来,团队里几个工程师平时用 Claude Code 写代码,他们希望分类能力能直接以插件形式嵌进 Claude Code 的工作流里,于是有了第三版。
同一个需求,三种实现路径,踩了三套完全不同的坑。这篇文章就把这三次实践完整拆开讲,包括每次选型的理由、具体的实现步骤、遇到的典型问题,以及我最后总结出来的选型判断标准。如果你也在纠结“一个 Agent 到底该直接调 API、上框架还是做成插件”,这篇应该能帮你少走点弯路。
2. 第一版:直接调 API,最快跑通也最容易翻车
2.1 为什么第一版不选框架
很多人一上来就想搭一套完整的 Agent 架构,我建议先忍住。判断标准很简单:如果你的任务是一次输入、一次输出、不需要多轮工具调用,那就别上框架。
工单分类恰好符合这个特征。输入是一段文本,输出是一个类别标签,中间不需要查数据库、不需要调外部工具、不需要多轮推理。这种场景下,框架带来的抽象层全是负担——你要理解它的 Agent 定义、工具注册、状态管理,最后发现核心逻辑还是那一次 API 调用。
直接调 API 的另一个好处是调试成本极低。出问题了,打印一下请求体和响应体,问题基本就定位了。框架出问题,你得先搞清楚是框架的哪一层出了问题,再往下钻。
2.2 核心实现:一次 API 调用的完整链路
我用的是 OpenAI 兼容格式的接口,Python 环境,依赖只有requests。核心逻辑分三步:构造 prompt、发请求、解析结果。
import requests import json API_URL = "https://your-api-endpoint/v1/chat/completions" API_KEY = "sk-xxxxxxxx" def classify_ticket(ticket_text): prompt = f"""你是一个客服工单分类助手。请将下面的工单归类到以下类别之一: 退款、物流、账号、产品咨询。 只输出类别名称,不要输出其他内容。 工单内容: {ticket_text} """ payload = { "model": "gpt-4o-mini", "messages": [{"role": "user", "content": prompt}], "temperature": 0, "max_tokens": 20 } headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } resp = requests.post(API_URL, json=payload, headers=headers, timeout=30) resp.raise_for_status() result = resp.json() return result["choices"][0]["message"]["content"].strip()这段代码有几个关键点值得说。
temperature 设为 0。分类任务要的是稳定输出,不是创意。temperature 越高,模型越可能给你输出“这看起来像是退款问题”这种带解释的句子,解析起来就麻烦了。
max_tokens 限制在 20。类别名称最多四个字,给 20 个 token 绰绰有余。限制输出长度能防止模型“话痨”,也能省点费用。
prompt 里明确要求“只输出类别名称”。这是最容易被忽略的一点。你不说清楚,模型很可能给你输出一段分析过程,然后你还要写正则去提取。与其事后解析,不如事前约束。
2.3 踩坑记录:那些让我半夜爬起来改代码的问题
坑一:401 报错,key 明明是对的。我第一次跑的时候遇到unexpected status 401 unauthorized: incorrect api key provided: sk-svcac****。排查了半天,发现是环境变量里多了一个换行符。从网页复制 key 的时候,末尾带了个不可见的\n。这种问题特别隐蔽,因为打印出来看着完全正常。解决办法是在代码里加一句API_KEY = API_KEY.strip(),养成习惯。
坑二:超长工单导致 400。有次坐席粘贴了一整段聊天记录进来,触发了this model's maximum context length is 1048576 tokens的报错。虽然这个上限看着很大,但实际业务里确实会遇到超长输入。我的处理方式是在调用前做一次截断,超过 8000 字符的部分直接砍掉,因为工单的核心诉求通常在开头。
坑三:并发上来之后接口开始超时。客服高峰期同时有几十个工单进来,同步请求直接排队。后来改成了批量处理,一次请求塞多个工单,让模型返回 JSON 数组。这里要注意,批量处理时 prompt 的结构要更严格,否则模型容易把多个工单的结果混在一起。
def classify_batch(tickets): numbered = "\n".join([f"{i+1}. {t}" for i, t in enumerate(tickets)]) prompt = f"""将下列工单分别分类到:退款、物流、账号、产品咨询。 以 JSON 数组格式返回,每个元素只包含类别名称,顺序与输入一致。 {numbered} """ # ... 请求逻辑同上,解析时用 json.loads批量处理把 QPS 压力降了一个数量级,代价是单次请求的 token 消耗变高,需要权衡批次大小。我实测下来,一批 10 个工单是比较稳的平衡点。
3. 第二版:上 AgentCore,从脚本到服务的跨越
3.1 什么时候该从 API 升级到 Agent 框架
第一版跑了一个月,需求开始变复杂。产品那边希望分类之后能自动触发后续动作:退款类工单自动查订单状态,物流类工单自动拉物流轨迹,账号类工单自动检查账号是否被冻结。这就不是一次 API 调用能解决的了,需要多步骤、多工具的编排。
这时候 Agent 框架的价值才真正体现出来。判断标准:当你的任务需要“根据中间结果决定下一步做什么”时,就该考虑 Agent 框架了。工单分类是固定流程,分类后触发什么动作是条件分支,这正是 Agent 擅长的场景。
我选 AgentCore 的原因有两个:一是它和现有的 API 调用方式兼容,迁移成本低;二是它的工具注册机制比较直观,不需要写太多胶水代码。
3.2 Agent 和普通 API 调用的本质区别
这里插一句概念澄清,因为很多人把这两个搞混。普通 API 调用是“你问它答”,Agent 是“你给目标它自己想办法”。
打个比方:API 调用像是你去餐厅点菜,你说“来份宫保鸡丁”,厨房做好端给你。Agent 像是你告诉服务员“我想吃点辣的、有花生的、下饭的”,服务员去跟厨房沟通,可能推荐宫保鸡丁,也可能推荐辣子鸡丁,甚至可能先问你一句“花生过敏吗”。
落到代码上,API 调用是你控制流程,Agent 是模型控制流程。这个区别决定了:API 调用的输出是可预测的,Agent 的输出需要做更多的容错处理。
3.3 工具注册与编排的实操细节
AgentCore 里,每个能力都要注册成一个工具。我注册了三个工具:查订单、查物流、查账号状态。
from agentcore import Agent, tool @tool def query_order(order_id: str) -> dict: """根据订单号查询订单状态""" # 实际调用内部订单系统 return {"order_id": order_id, "status": "已发货", "amount": 299.00} @tool def query_logistics(order_id: str) -> dict: """根据订单号查询物流轨迹""" return {"order_id": order_id, "latest": "已到达杭州转运中心"} @tool def query_account(user_id: str) -> dict: """查询账号状态""" return {"user_id": user_id, "status": "正常", "frozen": False} agent = Agent( model="gpt-4o", tools=[query_order, query_logistics, query_account], system_prompt="你是客服工单处理助手。先分类工单,再根据类别调用相应工具获取信息,最后给出处理建议。" )工具函数的 docstring 非常关键。Agent 是靠这段描述来判断什么时候该调用哪个工具的。我一开始写得很随意,结果 Agent 经常调错工具。后来把 docstring 写清楚,明确说明“这个工具在什么场景下使用”,准确率明显提升。
3.4 踩坑记录:Agent 的“自作主张”与安全边界
坑一:Agent 会调用不该调用的工具。有次一个产品咨询类工单,Agent 莫名其妙去查了订单。原因是工单里提到了“我上次买的那个”,触发了订单查询的关键词匹配。解决办法是在 system prompt 里加约束:“只有在工单明确涉及订单问题时才调用订单查询工具。”
坑二:工具调用失败后的处理。订单系统偶尔会超时,Agent 拿到空结果后不知道怎么办,会反复重试。我在工具函数里加了异常捕获,失败时返回一个明确的错误信息,让 Agent 知道“这条路走不通,换一条”。
坑三:Agent 的响应时间不可控。一次工单处理可能涉及 2-3 次工具调用,每次都要等模型决策,总耗时比直接 API 调用长不少。对于实时性要求高的场景,这个延迟需要提前评估。我的做法是给 Agent 设置最大工具调用次数,超过就强制返回当前结果。
4. 第三版:做成 Claude Code 插件,把能力嵌进工作流
4.1 为什么要把 Agent 做成插件
第二版上线后,工程师团队提了个需求:他们平时在 Claude Code 里写代码,遇到工单相关的 bug 时,希望能直接在编辑器里查工单分类结果,不用切到客服系统。这就引出了第三版——把分类能力做成 Claude Code 插件。
插件形态的价值在于场景嵌入。API 和 Agent 都是独立服务,用户需要主动去调用。插件是嵌在用户已有的工作流里的,用户不需要改变习惯,能力就自然触达了。
4.2 Claude Code 插件的基本结构
Claude Code 的插件机制基于 MCP(Model Context Protocol),核心是提供一个工具服务,让 Claude Code 能调用你的能力。插件目录结构大致如下:
ticket-classifier-plugin/ ├── manifest.json ├── server.py └── requirements.txtmanifest.json定义插件的基本信息和工具列表:
{ "name": "ticket-classifier", "version": "1.0.0", "description": "客服工单分类与查询工具", "tools": [ { "name": "classify_ticket", "description": "对工单文本进行分类", "parameters": { "type": "object", "properties": { "text": {"type": "string", "description": "工单内容"} }, "required": ["text"] } } ] }server.py实现具体的工具逻辑,本质上就是把第二版的 Agent 能力包装成一个 MCP 服务:
from mcp.server import Server from mcp.types import Tool, TextContent server = Server("ticket-classifier") @server.list_tools() async def list_tools(): return [Tool( name="classify_ticket", description="对工单文本进行分类", inputSchema={...} )] @server.call_tool() async def call_tool(name, arguments): if name == "classify_ticket": result = classify_ticket(arguments["text"]) return [TextContent(type="text", text=result)]4.3 插件开发中的关键决策
决策一:插件里放多少能力。我一开始想把所有功能都塞进插件,后来发现插件工具太多会让 Claude Code 的选择变困难。最后只保留了最核心的“分类”和“查询”两个工具,其他能力还是走独立服务。
决策二:本地执行还是远程调用。插件可以本地跑逻辑,也可以调用远程 API。我选了远程调用,因为分类模型和业务数据都在服务端,本地跑不现实。代价是插件依赖网络,离线环境下不可用。
决策三:错误处理策略。插件调用失败时,是返回错误信息还是静默失败?我选择返回明确的错误信息,让 Claude Code 知道发生了什么,这样用户能看到“工单服务暂时不可用”而不是莫名其妙没反应。
4.4 踩坑记录:插件调试的痛点
坑一:插件加载失败没有明确提示。Claude Code 加载插件失败时,报错信息往往很模糊。我的排查方法是先单独跑server.py,确认 MCP 服务本身能启动,再检查 manifest 配置。分步排查比一次性调试快得多。
坑二:工具描述写得太技术化。一开始我把工具描述写成“调用分类模型对输入文本进行多类别分类”,结果 Claude Code 很少主动调用。改成“帮我判断这个工单属于哪一类”之后,调用频率明显上升。工具描述要站在使用者的角度写,不是站在开发者的角度。
坑三:版本更新后插件失效。Claude Code 升级后,MCP 协议的某些字段变了,插件直接不工作。教训是插件开发要关注协议版本,在 manifest 里明确声明兼容的版本范围。
5. 三种方案的横向对比与选型建议
5.1 核心维度对比
| 维度 | 直接调 API | AgentCore | Claude Code 插件 |
|---|---|---|---|
| 开发成本 | 低,半天搞定 | 中,2-3 天 | 中高,3-5 天 |
| 适用场景 | 单轮输入输出 | 多步骤、多工具编排 | 嵌入已有工作流 |
| 调试难度 | 低 | 中 | 高 |
| 响应延迟 | 低 | 中高 | 取决于远程服务 |
| 扩展性 | 差 | 好 | 中 |
| 用户触达 | 需主动调用 | 需主动调用 | 场景内自然触达 |
| 运维复杂度 | 低 | 中 | 高 |
5.2 我的选型判断流程
每次遇到新需求,我会按这个顺序问自己几个问题:
- 任务是不是一次输入一次输出?是的话,直接调 API,别犹豫。
- 需不需要根据中间结果决定下一步?需要的话,上 Agent 框架。
- 用户是不是已经在某个工具里工作了?是的话,考虑做成插件嵌入。
- 团队有没有维护服务的能力?没有的话,优先选最简单的方案。
这个流程帮我避免了很多过度设计。我见过太多项目,明明一个 API 调用能解决,非要搭一套 Agent 架构,最后维护成本高得离谱。
5.3 一个容易被忽略的成本:模型调用的费用
三次实现里,模型调用费用差异很大。直接调 API 最省,因为只调一次。Agent 因为要多轮决策,费用可能是 API 的 3-5 倍。插件本身不增加模型调用,但如果插件触发了 Claude Code 的额外推理,费用也会上升。
我做过一个粗略统计:同样处理 1000 个工单,直接 API 调用花费约 2 元,Agent 方案约 8 元,插件方案约 10 元(含 Claude Code 侧的消耗)。量小的时候无所谓,量大了这个差异很可观。
6. 常见问题速查与避坑清单
6.1 认证与配置类问题
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 401 unauthorized | key 含空格或换行 | 调用前 strip() 处理 |
| 401 unauthorized | key 过期或权限不足 | 检查 key 有效期和权限范围 |
| 400 context length | 输入超过模型上限 | 截断输入或分批处理 |
| 连接超时 | 网络问题或服务端限流 | 加重试机制,设置合理超时 |
6.2 Agent 行为类问题
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 调用错误的工具 | 工具描述不清晰 | 重写 docstring,明确使用场景 |
| 反复重试失败工具 | 工具返回信息不明确 | 失败时返回明确错误信息 |
| 响应时间过长 | 工具调用轮次过多 | 设置最大调用次数限制 |
| 输出格式不稳定 | prompt 约束不足 | 明确输出格式要求 |
6.3 插件开发类问题
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 插件加载失败 | manifest 配置错误 | 单独测试 server 再查配置 |
| 工具不被调用 | 描述太技术化 | 用自然语言重写描述 |
| 升级后失效 | 协议版本不兼容 | 声明兼容版本范围 |
| 调用无响应 | 远程服务不可用 | 加超时和错误提示 |
6.4 几条用血泪换来的经验
经验一:先用最笨的办法跑通,再考虑优化。我第一版 API 调用虽然简陋,但它让我快速验证了需求可行性。如果一上来就搭 Agent,可能花了一周还在调框架,需求本身反而没验证。
经验二:prompt 的稳定性比模型能力更重要。同一个模型,prompt 写得好和写得差,效果差距可能比换模型还大。花时间打磨 prompt,比花时间比较模型性价比高。
经验三:给所有外部调用加超时和重试。不管是 API 调用、工具执行还是插件通信,网络问题永远存在。没有超时机制的代码,在生产环境就是定时炸弹。
经验四:日志要记全,但别记敏感信息。工单内容可能包含用户隐私,日志里要脱敏。但请求 ID、耗时、错误码这些要记全,排查问题时全靠它们。
经验五:别追求一次做对,留好回滚路径。三次实现我都是新开分支,旧版本继续跑着。新版本验证没问题再切换,出问题能快速回退。这个习惯救过我好几次。
7. 后续可以怎么扩展
这套东西跑到现在,我又在琢磨几个方向。一是把分类模型换成更小的本地模型,降低调用成本和延迟;二是把插件能力扩展到其他编辑器,让更多同事能用上;三是给 Agent 加上缓存层,相同工单不重复处理。
如果你也在做类似的东西,我的建议是别被“Agent”这个词吓到。它本质上就是个能自己决定下一步做什么的程序,核心还是把需求拆清楚、把边界定明白。工具选型没有绝对的对错,只有适不适合当前场景。先用最简单的方式跑通,遇到瓶颈再升级,这个节奏比什么都重要。