资讯动态

Agent Harness 从用到会造:上下文塞满了,它为什么反而变笨

发布时间:2026/10/14 23:13:19 来源:尧图企业网站定制
1. 从「会用」到「会造」Agent 的 harness 到底是什么我们先把一个经常被混用的概念说清楚。很多人以为Agent 的核心是那个大模型。其实不是。模型本身只会「根据输入预测下一个 token」它不会主动调用工具、不会循环、也不会记住自己做了什么。把这些能力串起来的是一层运行时代码业界通常叫它Agent Harness或者Agent Runtime。「harness」这个词很有意思。它本意是套在牛马身上的挽具、牵引绳。放在 Agent 语境里它指的是把模型这匹「马」套进工具循环里由它决定「调用哪个工具、传什么参数、什么时候停下来」由它维护对话历史、工具返回结果、系统提示词这一整套上下文context。所以你会看到真正的 Agent 系统代码里一定有一个类似下面的骨架工具调用最终回答接收用户任务组装上下文system history user调用 LLM模型返回的是工具调用还是最终回答执行工具把工具结果追加进上下文返回给用户这套骨架就是 harness。而本文要讨论的核心问题是为什么很多 Agent 用着用着上下文塞得越满它反而越「笨」以及更进一步的——我们自己怎么亲手造一个能管住上下文的 harness。我们先从一个能复现「变笨」的现场开始。2. 先复现那个「变笨」的现场在谈优化之前我们先得把问题「逼出来」。下面我们写一个没有上下文管理能力的最小 Agent然后让它在多轮工具调用中跑一跑。先定义两个基础数据结构fromdataclassesimportdataclass,fieldfromtypingimportAny,Callable,OptionaldataclassclassMessage:harness 内部维护的一条消息。role:str# system / user / assistant / toolcontent:strtool_call_id:Optional[str]Nonename:Optional[str]NonedataclassclassTool:一个可以被模型调用的工具。name:strdescription:strparameters:dict# JSON Schema 风格参数定义func:Callable# 真正的执行函数为了不依赖某个特定厂商我们对 LLM 做一层薄封装。下面以 OpenAI 兼容接口为例读者换成自己熟悉的 SDK 即可fromopenaiimportOpenAIclassLLMClient:def__init__(self,base_url:str,api_key:str,model:str):self.clientOpenAI(base_urlbase_url,api_keyapi_key)self.modelmodeldefchat(self,messages:list[Message],tools:list[dict]|NoneNone):kwargs:dict[str,Any]{model:self.model,messages:[{role:m.role,content:m.content}forminmessages],}iftools:kwargs[tools]toolsreturnself.client.chat.completions.create(**kwargs)然后是 harness 的主体。注意这是最朴素、完全不设防的版本classNaiveAgentHarness:没有任何上下文管理策略的 Agent 运行时。def__init__(self,llm:LLMClient,tools:list[Tool],system_prompt:str,max_iterations:int20):self.llmllm self.tools{t.name:tfortintools}self.tool_schemas[{type:function,function:{name:t.name,description:t.description,parameters:t.parameters,},}fortintools]self.system_promptsystem_prompt self.max_iterationsmax_iterationsdefrun(self,user_query:str)-str:# 上下文从这里开始只进不出messages:list[Message][Message(rolesystem,contentself.system_prompt),Message(roleuser,contentuser_query),]for_inrange(self.max_iterations):respself.llm.chat(messages,self.tool_schemas)choiceresp.choices[0]# 模型直接给出最终答案结束循环ifchoice.finish_reasonstopandchoice.message.content:returnchoice.message.content# 模型要求调用工具tool_callschoice.message.tool_callsor[]ifnottool_calls:# 没有工具调用也没有文本只能强制停止returnchoice.message.contentor空回复# 关键一步把 assistant 的 tool_calls 原样记进上下文messages.append(Message(roleassistant,contentchoice.message.contentor,tool_call_idtool_calls[0].idiflen(tool_calls)1elseNone,))# 逐条执行工具并把结果追加进上下文fortcintool_calls:toolself.tools[tc.function.name]importjson argsjson.loads(tc.function.argumentsor{})resulttool.func(**args)messages.append(Message(roletool,contentstr(result),tool_call_idtc.id,nametc.function.name,))raiseRuntimeError(达到最大迭代次数仍未完成任务)这个 harness 看起来完全正常也能跑通简单的「问天气」「查资料」任务。问题出在哪问题出在那一句注释里「上下文从这里开始只进不出」。3. 为什么上下文越满模型反而越糊涂3.1 「只进不出」的上下文是一颗定时炸弹假设我们的 Agent 接了一个长任务从一份大型知识库中检索资料一步步完成一份调研报告。每轮工具调用可能返回几千 token 的文档片段。我们做一个简单的 token 估算。先给出一个粗略的计数函数生产环境建议用 tiktoken 或模型自带 tokenizerdefestimate_tokens(text:str)-int:粗略估算中文一个字约 1.5 token英文一个词约 1.3 token。importre chinese_charslen(re.findall(r[\u4e00-\u9fff],text))english_wordslen(re.findall(r[A-Za-z],text))otherslen(text)-chinese_chars-english_wordsreturnint(chinese_chars*1.5english_words*1.3others*0.5)classNaiveAgentHarness:# ... 上面的代码 ...defcontext_size(self,messages:list[Message])-int:totalsum(estimate_tokens(m.content)forminmessages)forminmessages:ifm.roletool:# 工具消息还有协议字段开销粗略加一点total4returntotal我们来模拟一次运行让 Agent 连续检索 20 次每次工具返回 2000 token 左右的文本deffake_search(query:str)-str:模拟一个返回大段文档的工具。return .join([f资料片段-{query}-{i}-内容*300foriinrange(5)])# 单次工具返回约 2000 token# 检索 20 次后光工具返回内容就是 20 * 2000 40000 token真实世界里的情况会更糟每个工具调用还会产生 assistant 的 tool_call 消息system prompt 可能本身就有一两千 token用户最初的长文档还在上下文里模型每次生成也要占窗口。结果就是几十轮之后上下文必然撞上模型的上下文窗口上限context window。3.2 撞墙之后的行为才是真正「变笨」的开始当上下文超过窗口上限时harness 只有两个选择而两个选择都会让 Agent 变笨选择一直接报错。任务中断前面的工作全部作废。选择二自动截断。很多框架会选择丢弃最早的消息。于是最老的 system prompt用户原始指令中间轮次的工具结果最近的对话一旦自动截断被丢掉的往往是system prompt 和用户最初的指令。这些正是任务目标、约束条件、输出格式要求所在。丢了它们模型就失去了「任务定义」。这就是第一个变笨机制不是模型能力下降了而是它赖以判断「我该干什么」的上下文被物理删除了。3.3 即便不截断「中间信息」也会被注意力稀释还有一个更隐蔽的机制即使上下文还没到窗口上限只是「很长」模型的表现也会下降。这就是著名的Lost in the Middle迷失在中间现象。我们可以写一个实验框架来验证把关键信息放在不同位置观察模型能否找出来。defbuild_long_context(key_info:str,position:str,total_parts:int40): 构造一段长文本关键信息放在开头、中间或结尾。 position: head / middle / tail filler这是一段与问题无关的填充文本用来拉长上下文。*20parts[f第{i1}段。{filler}foriinrange(total_parts)]key_linef【重要】本任务的答案密钥是{key_info}。ifpositionhead:parts.insert(1,key_line)elifpositiontail:parts.append(key_line)else:parts.insert(total_parts//2,key_line)text\n\n.join(parts)question请找出上面文本中的「答案密钥」并原样输出。returntext\n\nquestion# 运行三次分别把密钥放在开头/中间/结尾forposin[head,middle,tail]:promptbuild_long_context(K-8848-ALPHA,pos)# resp llm.chat([Message(roleuser, contentprompt)])# 记录模型是否答对、答案是否完整print(f关键信息在{pos}位置模型回答需要人工观察或程序比对)大量实验以及语言模型领域的公开研究表明模型对开头和结尾的内容记得最清楚对中间部分的信息召回率明显下降。而 Agent 的长任务恰好会产生「中间信息」——第 7 轮检索到的关键线索、第 12 轮的中间结论都堆在上下文的中部。模型在多轮决策时很可能「看不见」这些中间信息。3.4 三个机制叠加「变笨」是必然结果总结一下上下文塞满后 Agent 变笨是三个机制叠加的结果物理丢失超出窗口被截断最关键的早期指令被删掉注意力稀释即使都在窗口内中间信息也难以被稳定召回噪声干扰大量无关的工具返回内容挤占注意力模型更容易「跟着噪声走」忘记最初的约束。而几乎所有「变笨」的表象——答非所问、忘记指令、重复调用同一个工具、循环不停——都能归结到这三条上。4. 手搓一个最小 Agent Harness先把地基打牢理解了原理我们开始「会造」。先从把 harness 的核心循环写干净开始然后逐步给它装上「上下文管理」的大脑。4.1 核心循环可插拔的策略设计我们把上一节的朴素实现升级一下。关键设计是上下文管理策略与执行循环解耦。这样我们可以像换插件一样替换不同的压缩、裁剪算法。先定义一个策略接口fromabcimportABC,abstractmethodclassContextStrategy(ABC):上下文管理策略的抽象基类。abstractmethoddefoptimize(self,messages:list[Message],budget_tokens:int)-list[Message]: 接收当前消息列表返回优化后的消息列表。 保证优化后的 token 数不超过 budget_tokens尽力而为。 ...然后是升级后的 harnessclassManagedAgentHarness:带上下文管理能力的 Agent 运行时。def__init__(self,llm:LLMClient,tools:list[Tool],system_prompt:str,context_strategy:ContextStrategy,max_iterations:int20,context_budget_tokens:int8000,):self.llmllm self.tools{t.name:tfortintools}self.tool_schemas[{type:function,function:{name:t.name,description:t.description,parameters:t.parameters,},}fortintools]self.system_promptsystem_prompt self.context_strategycontext_strategy self.max_iterationsmax_iterations self.context_budget_tokenscontext_budget_tokensdef_context_size(self,messages:list[Message])-int:totalsum(estimate_tokens(m.content)forminmessages)returntotaldefrun(self,user_query:str)-str:messages:list[Message][Message(rolesystem,contentself.system_prompt),Message(roleuser,contentuser_query),]for_inrange(self.max_iterations):# ---- 上下文治理的入口每次调用模型前先瘦身 ----messagesself.context_strategy.optimize(messages,self.context_budget_tokens)respself.llm.chat(messages,self.tool_schemas)choiceresp.choices[0]ifchoice.finish_reasonstopandchoice.message.content:returnchoice.message.content tool_callschoice.message.tool_callsor[]ifnottool_calls:returnchoice.message.contentor空回复tool_call_idtool_calls[0].idiflen(tool_calls)1elseNonemessages.append(Message(roleassistant,contentchoice.message.contentor,tool_call_idtool_call_id,))importjsonfortcintool_calls:toolself.tools[tc.function.name]argsjson.loads(tc.function.argumentsor{})try:resulttool.func(**args)exceptExceptionase:# noqa: BLE001# 工具执行失败也要把错误回传给模型让模型自己纠错resultf工具执行失败{e}messages.append(Message(roletool,contentstr(result),tool_call_idtc.id,nametc.function.name,))raiseRuntimeError(达到最大迭代次数仍未完成任务)注意两个设计点每次调用 LLM 之前先经过context_strategy.optimize而不是等到报错才处理工具执行异常也会进入上下文。这很重要模型需要知道工具挂了而不是傻等。地基打好了下面来填策略。5. 给上下文「减负」三种实战策略5.1 策略一滑动窗口——最简单也最危险滑动窗口的做法是只保留最近 N 条消息丢掉更早的。classSlidingWindowStrategy(ContextStrategy):只保留最近 window_size 条消息更早的直接丢弃。def__init__(self,window_size:int16):self.window_sizewindow_sizedefoptimize(self,messages:list[Message],budget_tokens:int)-list[Message]:# system 消息永远保留它是任务的根本headmessages[:1]tailmessages[-(self.window_size-1):]returnheadtail优点实现简单、O(1) 复杂度、永远不会超限。但它的缺点就是我们在 3.2 节分析的早期关键信息会被物理删除。如果 system prompt 里写了「回答必须用 JSON 格式」滑动窗口删掉用户前几轮强调过的细节不要紧但如果删掉了用户的原始任务Agent 就彻底跑偏。滑动窗口适合「短任务、强 system prompt、不需要长期记忆」的场景。5.2 策略二自动截断的 token 预算版——按预算裁剪比条数窗口更实用的是按 token 预算裁剪。策略是system 不动从最老的非 system 消息开始丢弃直到满足预算。classTokenBudgetStrategy(ContextStrategy):按 token 预算裁剪system 保留旧消息从前往后丢弃。defoptimize(self,messages:list[Message],budget_tokens:int)-list[Message]:ifnotmessages:returnmessages system_msgs[mforminmessagesifm.rolesystem]rest[mforminmessagesifm.role!system]# system 单独累积不受裁剪system_sizesum(estimate_tokens(m.content)forminsystem_msgs)# 从最旧的非 system 消息开始丢keptlist(rest)whilekept:totalsystem_sizesum(estimate_tokens(m.content)for

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑