无保护代理:当模型缺乏防护时会发生什么问题
在Claude Agent SDK和LangChain Deep Agents中构建相同的无保护旅行代理,然后观察三次失败,以确定缺少了哪些控制机制。
来源: Harness Engineering: The Naked Agent: Why Your Framework Hands You a Loop, Not a Harness — I
有顾客要求客服重新预订航班。模型表现得十分自信,工具调用也成功了,回复称一切已处理完毕。但随后有人发现日期竟是3月32日。
就在那一刻,幻想破灭了。模型并不缺乏智能,问题出在它周围的整个系统——因为没有任何组件知道何时该提出质疑。接下来的测试使用的是一个极其简化的客服系统:没有验证机制、没有记忆功能、没有恢复能力、也没有安全保障。通过观察它的失败,可以揭示生产环境中的客服系统存在哪些问题,同时也表明语言模型运行时的环境比模型本身更为重要。人们用Claude Agent SDK和LangChain Deep Agents分别构建了两个同样简化的客服系统,结果它们都以三种不同的方式出错,每一种错误都指向了真实客服系统必须具备的控制功能。最终得到了一种区分模型问题与系统问题方法。
3月32日的事件
想象一下,一名旅游预订代理收到了真实用户发送的“3月32日”这条短信。该模型直接将这条信息传递给了航班查询API。大约40分钟后,模型又产生了50美元的调用费用,最终还得有人向那位已被告知一切已处理完毕的客户解释根本不存在的预订情况。
该模型并未生成任何内容,它只是原封不动地传递所接收到的信息,因为控制框架并未规定它必须如此操作。事情就是这样。智能来自模型,而约束则来自控制框架,因此当某个执行代理行为不当时,问题几乎总出在约束层。更换更强大的模型或优化提示语无法创造出原本就不存在的控制机制,而构建控制机制的前提是先了解其缺失会带来多大的代价。正因如此,最初的设定极为简单:一个脆弱的代理,两个框架,没有控制框架。
运行时起主导作用的证据
基准测试数据支持这一观点。普林斯顿大学的研究人员将模型锁定在GPT-4 Turbo版本,仅修改了连接该模型与代码库的接口代码。在权重保持不变的情况下,SWE-bench测试中问题得到解决的比率从3.8%上升至12.47%,几乎增长了两倍。这项名为“SWE-agent:智能体与计算机接口实现自动化软件工程”的研究发表于2024年,并在那一年被NeurIPS会议接受。带来改进的是接口设计,而非模型本身的智能。
这一领域有一个更早的名称——机械共鸣:即编写能够与底层硬件或平台协同工作的软件,而非与之相冲突。Harness工程将这种理念应用到了全新的载体——具有上下文窗口和注意力预算的语言模型上。模型的性能设定了上限,而Harness则决定系统能多稳定地接近这一上限。
思维方式的转变改变了失败后的应对方式:
- 以前:得到错误答案后,调整提示词,增加各种限制条件,尝试使用更大的模型并寄予希望
- 现在:得到错误答案后,找出缺失的控制机制,将该规则从提示词中移除并放入运行时处理
提示词用于说服,而约束机制则用于强制执行。
每个框架背后的循环
所有的智能体框架都基于同一个五步循环:向模型提供工具,接收工具调用结果,执行该工具,返回结果并获取下一步操作指令。Anthropic的工具使用文档、OpenAI的Responses API以及LangChain的智能体运行时本质上都是同一原理。虽然SDK会不断更迭,但循环结构始终不变。
最简版本使用原始的提供者SDK、两个工具函数,且没有任何安全保障:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
TOOLS = [
{"name": "search_flights",
"description": "Search flights between two cities for a date.",
"input_schema": {"type": "object", "properties": {
"origin": {"type": "string"}, "destination": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"}},
"required": ["origin", "destination", "date"]}},
{"name": "book_flight",
"description": "Book a specific flight.",
"input_schema": {"type": "object", "properties": {
"flight_id": {"type": "string"}, "passenger_name": {"type": "string"}},
"required": ["flight_id", "passenger_name"]}},
]
def run_naked(user_msg: str) -> str:
messages = [{"role": "user", "content": user_msg}]
while True: # ① no iteration cap
resp = client.messages.create(
model="claude-sonnet-4-6", max_tokens=1024,
tools=TOOLS, messages=messages,
)
if resp.stop_reason != "tool_use":
return resp.content[0].text
call = next(b for b in resp.content if b.type == "tool_use")
result = dispatch(call.name, call.input)
# ② direct side effect, no check
messages.extend([
# ③ whole history, every turn
{"role": "assistant", "content": resp.content},
{"role": "user", "content": [{"type": "tool_result",
"tool_use_id": call.id, "content": result}]},
])
阅读时请牢记以下三个潜在问题:
while True:循环没有上限。由于没有限制迭代次数,出错的计划会不断调用模型,直到预算耗尽。dispatch函数会直接将模型提供的参数传递给某个副作用处理函数,而不做任何预先检查,因此那些虽然能被解析但实际并不存在的值也会传送到实时API中。- 完整的消息历史记录会在每轮对话中被扩展并重新发送,从而悄悄增加上下文量,最终导致质量下降。
所有你希望具备的安全特性都不应存在于这个循环中。它们存在于尚未编写的代码里。即便使用了框架,问题也并未解决。Claude Agent SDK与Deep Agents虽然提供了结构更完善的循环机制,具备规范的工具协议和清晰的执行路径,但默认情况下并不提供任何验证机制、可被利用的内存管理功能,也没有恢复机制。循环本身只是被呈现出来,而相关的整合工作仍需开发者自行完成。
以下是在各个框架中相同的原始代理实现。
Claude Agent SDK版本
SDK会为你封装好这个循环。工具通过@tool装饰器进行声明,以进程内MCP服务器的形式暴露出来,并通过query()函数来执行。不过它缺乏任何控制功能:参数仅会根据架构规范进行校验,调用之间不会保留任何信息,也没有恢复机制。
import asyncio
from claude_agent_sdk import (
query, ClaudeAgentOptions, tool,
create_sdk_mcp_server, AssistantMessage, ResultMessage,
)
@tool("search_flights", "Search flights between two cities for a date.",
{"origin": str, "destination": str, "date": str})
async def search_flights(args):
# ① no check that date exists
hits = flights_api.search(**args)
return {"content": [{"type": "text", "text": str(hits)}]}
@tool("book_flight", "Book a specific flight.",
{"flight_id": str, "passenger_name": str})
async def book_flight(args):
# ② destructive, ungated
confirmation = flights_api.book(**args)
return {"content": [{"type": "text", "text": confirmation}]}
server = create_sdk_mcp_server("travel", tools=[search_flights, book_flight])
async def main():
options = ClaudeAgentOptions(
mcp_servers={"travel": server},
allowed_tools=["mcp__travel__search_flights",
"mcp__travel__book_flight"],
)
async for msg in query(prompt="Rebook this customer for March 32nd.",
options=options):
if isinstance(msg, AssistantMessage):
for b in msg.content:
if hasattr(b, "text"):
print(b.text)
elif isinstance(msg, ResultMessage):
print("done:", msg.subtype)
# ③ no state survives this run
有三点尤为突出:
search_flights会接受模型提出的任何符合架构的数据,因此无效日期也会被传入工具处理流程。book_flight具有破坏性,且没有任何防护机制。装饰器将其直接接入循环中,既没有审批环节也没有验证机制。- 当
ResultMessage生成且query()返回结果后,当前会话即告结束,该次运行中的任何数据都不会保留到下一次调用中。
该SDK提供了一个简单的循环结构,却无法处理无效日期、重复收费或需要记录的其他问题。
Deep Agents版本
Deep Agents 以不同的方式处理循环逻辑。普通的 LangChain 工具会进入 create_deep_agent() 函数,该函数会返回一个已编译的图结构以便后续的 invoke 调用。两者的实现方式其实相同:都没有验证机制,而且由于没有提供检查点,因此图结构会在每次调用之间忘记所有信息。
from langchain.tools import tool
from deepagents import create_deep_agent
@tool
def search_flights(origin: str, destination: str, date: str) -> str:
"""Search flights between two cities for a date (YYYY-MM-DD)."""
return str(flights_api.search(origin, destination, date))
# ① no date check
@tool
def book_flight(flight_id: str, passenger_name: str) -> str:
"""Book a specific flight."""
return flights_api.book(flight_id, passenger_name)
# ② ungated side effect
agent = create_deep_agent(
# ③ the loop, no controls
model="anthropic:claude-sonnet-4-6",
tools=[search_flights, book_flight],
)
result = agent.invoke({"messages": [{"role": "user",
"content": "Rebook this customer for March 32nd."}]})
print(result["messages"][-1].content)
# Ask a follow-up in a second invoke, and it starts from zero: no thread,
# no memory.
还是三点:
search_flights函数会直接使用原始的date字符串调用API,在请求发出之前没有任何机制来验证日期的真实性。book_flight函数在模型选定后立即执行预订操作,从提议到产生实际影响之间没有任何缓冲机制。create_deep_agent函数仅返回一个能运行循环的图结构,除此之外别无其他功能。由于没有检查点机制,每次调用之间既没有线程控制也没有内存管理。
裸代理会出问题的三种方式
有两个框架提供了结构清晰的循环结构,但却缺乏任何约束机制,这正是2026年“裸代理”的真实面貌。它并非虚构的模型,而是每周上传到GitHub的数十个代理演示项目的共同特征。在真实使用场景下,它会以三种方式出现故障,而每种故障都对应着合理框架所具备的管控机制。
无效输入。当输入“3月32日”时,模型会按照常规操作,将date字段填入一个格式正确但毫无意义的字符串。架构仅能确认date是字符串类型,却无法判断该日期是否真实存在。于是book_flight函数会针对一个并不存在的行程执行操作,而模型却会报告操作成功。
该调用使用虚构的航班编号和乘客姓名调用book_flight函数,最终返回“已预订”。虽然操作确实被执行了,但循环中没有任何代码检查这一操作是否真的有必要。
book_flight(flight_id=”AC-PHANTOM”, passenger_name=”J. Moffatt”)
# -> “Booked.” The action fired. Nothing in the loop asked whether it should.
故障源于缺乏动作边界,模型提出的方案与实际发生的操作之间没有验证机制。填补这一空白正是工具合约与验证器的职责。
过大的上下文。原始循环会在每一步都重新发送完整的历史记录。一段包含大量搜索结果、政策文本及重试操作的漫长重新预订流程可能会超过五万个标记,而没有任何机制对其进行剪裁。正如相关文献所称的“上下文腐化”现象就会随之出现,智能体会在同一轮次中丢弃之前已正常完成的步骤。这不会引发任何错误,只是让智能体的表现逐渐变差。
此处缺失的组件是工作记忆与上下文整合功能,它们分别属于内存与持久状态范畴以及上下文整合功能范畴。
被掩盖的错误。某个工具返回错误信息,而模型往往会写出委婉的总结,刻意回避故障细节。有一项针对生产事故的字段分析将相关处理机制描述为“自信的骗子”:返回零条记录的数据库会被视为事实性答案,而非故障表现。选择合适的工具从来都不是难题,真正困难的是区分那些真正能正常工作的工具与那些仅能伪装正常工作的工具。此处并未观察到反馈循环机制,而建立反馈循环则属于可观测性与偏差检测的范畴。
应将这些问题视为诊断模式,而非令人尴尬的清单。在所有三种情况下,模型都只是做了模型该做的事,只是周边系统未能察觉到。这些案例中都没有模型不够智能的问题。
用于资源调度的有效循环机制
在构建任何控制机制时都会重复一个四步流程:
- 引入约束函数及其所控制的故障场景。
- 在两个框架中分别以独立代码片段的形式实现该函数。
- 触发故障以证明该控制机制能够承受负载。
- 检查约束函数对此的响应情况。
上述步骤已三次执行了该流程,但并未明确标注:首先找出未加保护的接口,再触发由此产生的故障,最后确定该故障所对应的控制机制。之后再逐一处理这些接口。
亲自尝试
无需继续阅读就能感受到其中的问题。在编辑器中通过几项快速实验即可发现:
- 在任一框架中,让未加保护的代理指向“为客户重新预订3月32日的航班”这一指令,观察这个不合理的日期如何被传入
book_flight函数中。
book_flight函数返回{"is_error": true, ...},并检查最终汇总结果是否如实反映了失败情况或刻意隐瞒了问题。claude-agent-sdk版本以及一个deepagents或langchain版本的问题,因为这两个接口的版本会不断变动,同时将模型名称(如claude-sonnet-4-6、anthropic:claude-sonnet-4-6)视为当前可用版本的标识。每个框架都能提供结构清晰、设计精良的循环机制,但都不会提供配套的管控工具。这是该情况的固有特点,而非工具的缺陷;真正的挑战正在于此。Claude Code、OpenCode、Codex以及Deep Agents都属于智能体管控工具,那些在使用这些工具时遇到困难的开发者,与那些通过设计出循环机制所缺失的管控措施来获得稳定结果的开发者之间存在差异。模型早已成为相对简单的一部分,而可靠性则源于围绕模型建立的规范体系,以及通过一次次失败积累的经验;其中最基础的部分就是行为边界,也就是第一条让模型脱离提示词、在运行时环境中工作的规则。