我用裸API写了一个Agent,踩了5个坑
标签:AI、Agent、大模型、Python、架构 预计阅读时间:10分钟
先说结论
Anthropic官方说:最成功的Agent实现用的不是复杂框架,而是简单组合模式。我照着这个思路,用裸Python写了一个客服Agent,不加任何框架。跑通是跑通了,但踩了5个坑,每个都值得记下来。
起因
上个月我们团队要做一个内部工单处理Agent——员工提交IT问题,Agent自动分类、查知识库、尝试解决,解决不了再转人工。需求不复杂,但我看了看LangChain的教程,总觉得为了这么个东西引入一整个框架有点重。正好Anthropic发了那篇Building Effective Agents,我就决定用裸API试试。
架构设计:路由 + 提示链
Anthropic把AI应用分成Workflow和Agent两种。Workflow是代码控制流程,Agent是LLM自己决定下一步。我的场景很明确——工单处理有固定步骤,Workflow就够了。
架构其实很简单:第一步路由分类,第二步根据分类走不同处理流程,第三步生成回复。整个过程代码控制,LLM只负责理解和生成文本。
import anthropic
import json
client = anthropic.Anthropic()
def classify_ticket(ticket_text: str) -> str:
"""第一步:路由分类"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=50,
messages=[{
"role": "user",
"content": f"""把以下IT工单分到这几类中的一类:网络、账号、软件、硬件、其他。
只输出类别名,不要解释。
工单内容:{ticket_text}"""
}]
)
return response.content[0].text.strip()
def handle_network_ticket(ticket_text: str) -> str:
"""第二步:网络类工单处理"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
messages=[{
"role": "user",
"content": f"""你是IT支持工程师。用户报了网络问题,先给排查步骤,如果常见方案解决不了就建议转人工。
工单内容:{ticket_text}"""
}]
)
return response.content[0].text
def process_ticket(ticket_text: str) -> dict:
"""主流程:路由 + 处理"""
category = classify_ticket(ticket_text)
handlers = {
"网络": handle_network_ticket,
"账号": handle_account_ticket,
"软件": handle_software_ticket,
"硬件": handle_hardware_ticket,
}
handler = handlers.get(category, handle_other_ticket)
reply = handler(ticket_text)
return {"category": category, "reply": reply}
看起来很美好对吧?跑起来就出事了。
坑1:分类结果不可控
第一个坑,路由分类的输出格式不稳定。我让它只输出类别名,但Haiku有时候会输出"网络问题"而不是"网络",有时候还带句号。后面的handlers.get()直接匹配不上,走到了handle_other_ticket。
# 我最初以为这样就够了
category = classify_ticket(ticket_text)
handler = handlers.get(category, handle_other_ticket) # 经常落进other
修复:在分类Prompt里加few-shot示例,再对输出做模糊匹配。
def classify_ticket(ticket_text: str) -> str:
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=50,
messages=[{
"role": "user",
"content": f"""把IT工单分类。只输出一个类别词。
例子:
"连不上WiFi" → 网络
"Outlook登录失败" → 账号
"Excel卡死" → 软件
"鼠标不动" → 硬件
"食堂太难吃" → 其他
工单:{ticket_text}"""
}]
)
raw = response.content[0].text.strip()
# 模糊匹配:输出包含类别关键词就算匹配
for keyword in ["网络", "账号", "软件", "硬件"]:
if keyword in raw:
return keyword
return "其他"
加了few-shot之后,准确率从大概70%提到了95%以上。模糊匹配兜底,基本不会再漏。
坑2:工具调用比我想的难
第二个坑,我想让Agent查知识库。按Anthropic的说法,给LLM加工具就行——但实际做起来,结构化输出的解析比写Prompt麻烦多了。
我一开始想让LLM自己决定要不要查知识库,结果它太积极了,什么问题都先查一遍,包括"谢谢"和"你好"。后来改成先分类,只有特定类别才查知识库,效果好了很多。
教训:别让LLM决定"要不要用工具",让代码决定。 LLM只负责"怎么用工具"。
def search_knowledge_base(query: str) -> str:
# 模拟知识库搜索
kb = {
"VPN连接失败": "1. 检查网络连接 2. 重启VPN客户端 3. 清除DNS缓存:ipconfig /flushdns",
"邮箱密码重置": "访问 portal.company.com/reset 按提示操作",
}
for key, value in kb.items():
if key in query:
return value
return "未找到相关文档"
def handle_network_ticket(ticket_text: str) -> str:
# 代码决定要查知识库,不是LLM决定
kb_result = search_knowledge_base(ticket_text)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=500,
messages=[{
"role": "user",
"content": f"""你是IT支持工程师。用户报了网络问题。
知识库搜索结果:{kb_result}
基于知识库内容给用户回复,如果知识库没有覆盖到,说明建议转人工。
工单:{ticket_text}"""
}]
)
return response.content[0].text
坑3:没有暂停机制,Agent跑飞了只能kill
第三个坑是最要命的。有次我给Agent加了个"自动执行"模式——如果LLM判断可以自动操作(比如重置密码),就直接执行。结果有一天,Agent连续给10个人发了密码重置邮件,其中一个是不该重置的管理员账号。
原因:LLM在不确定的情况下也输出了"可以自动执行"的判断,代码没有做二次确认。
修复:加了一层人工审批卡点。LLM只能建议"可以自动执行",代码必须等人工确认后才真正执行。
def should_auto_execute(category: str, ticket_text: str) -> bool:
"""LLM建议是否可以自动执行,但必须人工确认"""
if category == "账号":
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=10,
messages=[{
"role": "user",
"content": f"""这个账号工单能否安全地自动处理?只回答是或否。
工单:{ticket_text}"""
}]
)
llm_says_yes = "是" in response.content[0].text
# 即使LLM说可以,管理员账号必须人工处理
if "管理员" in ticket_text or "admin" in ticket_text.lower():
return False
return llm_says_yes
return False
def process_ticket_with_approval(ticket_text: str) -> dict:
result = process_ticket(ticket_text)
if should_auto_execute(result["category"], ticket_text):
result["needs_approval"] = True # 标记需要人工确认,但不自动执行
result["auto_action"] = "重置密码"
else:
result["needs_approval"] = False
return result
12-Factor Agents里有一条叫"用工具调用联系人类"——说的就是这个意思。让人类介入不应该是个异常分支,应该是Agent的正常能力。
坑4:上下文窗口不是数据库
第四个坑,我想在对话中保持上下文,让Agent记住之前的工单历史。最直觉的做法是把所有历史塞进messages数组。但跑到第8轮对话左右,输入token就爆了,Haiku直接报错。
# 错误做法:无脑堆历史
messages = []
for msg in conversation_history:
messages.append(msg) # 越来越长,最终超限
修复:自己管上下文窗口。只保留最近3轮对话,更早的对话压缩成摘要。
def build_messages(history: list[dict], current_query: str) -> list[dict]:
"""只保留最近3轮,更早的压缩成摘要"""
if len(history) <= 6: # 3轮 = 6条消息(3个user + 3个assistant)
recent = history
summary = ""
else:
recent = history[-6:]
# 把更早的对话压缩
old_text = "\n".join(
f"{'用户' if m['role']=='user' else '助手'}:{m['content'][:100]}"
for m in history[:-6]
)
summary_response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=100,
messages=[{
"role": "user",
"content": f"用一句话总结这段对话的关键信息:\n{old_text}"
}]
)
summary = summary_response.content[0].text
messages = []
if summary:
messages.append({
"role": "user",
"content": f"[之前的对话摘要:{summary}]"
})
messages.extend(recent)
messages.append({"role": "user", "content": current_query})
return messages
12-Factor Agents里说"拥有你的上下文窗口"——不是塞越多越好,你得主动管。就是这个意思。
坑5:调试框架代码比自己写的代码难十倍
第五个坑其实不是裸API的坑,是后来我试着用LangChain重写时碰到的。
我花了半天时间用LangChain重写了一遍同样的功能,代码确实短了不少——从150行缩到60行。但出了个bug:Agent有时候会跳过分类步骤直接回答。我翻LangChain的源码翻了两个小时,发现问题出在AgentExecutor的默认配置——它有个max_iterations参数,默认值太小,复杂任务会提前终止。
这个bug如果是在我自己的裸API代码里,5分钟就能定位。因为每一步都是我自己写的,我清楚地知道流程走了哪里。但LangChain帮我管了流程,我反而不知道流程走了哪里。
这不是说LangChain不好——对于复杂的多Agent协作场景,框架确实能省很多事。但对于我这种路由+提示链的简单Workflow,框架的抽象层反而增加了调试成本。
最终我还是用了裸API版本上线,代码不到200行,跑了三周没出问题。
回头看
这次实践让我理解了Anthropic那篇文章的核心观点:先用最简单的模式实现,只在真正碰到瓶颈时才加复杂度。我的场景就是一个路由加提示链,用Workflow完全够了,根本不需要Agent模式。
12-Factor Agents那几条原则,现在回头看每一条都是我踩过的坑:拥有你的提示词(我的few-shot就是自己管的)、拥有你的上下文窗口(我自己做了摘要压缩)、拥有你的控制流(代码决定查不查知识库,不是LLM决定)、让人类介入成为正常能力(审批卡点)。
说白了,Agent是软件工程问题。LLM只是其中一个组件,和数据库、缓存、消息队列没本质区别。你得用写后端服务的心态来写Agent——考虑边界条件、做异常处理、加监控告警。给它一个Prompt然后祈祷,那不叫工程,叫许愿。