1. 长对话 Agent 为什么越跑越慢:从 Harness 上下文膨胀说起
如果你正在用 AI Agent Harness 搭一个长期在线的客服、助理或者办公类 Agent,大概率会遇到一个很典型的现象:前 10 轮对话又快又准,跑到第 20 轮之后,响应时间从 1 秒出头涨到 3 秒以上,单次请求的 token 消耗翻了好几倍,偶尔还会出现 Agent 把用户三轮前刚说过的订单号忘掉、反复追问的情况。这不是模型变笨了,而是 Harness 默认的上下文管理策略在长会话里开始失控。
AI Agent Harness 本质上是一层 Agent 编排框架,它把大模型调度、工具调用、记忆管理、异常处理这些通用能力封装好,让你不用从零写胶水代码。它的记忆模块默认走的是全量上下文传递:只要会话没结束,所有历史消息——用户 Query、Agent 回答、工具调用参数、工具返回结果、系统提示词——都会被完整拼进每一次大模型请求。短会话下这没问题,但长期运行场景下,上下文长度会随轮数线性增长,直接带来两个后果。
第一是成本。大模型按 token 计费,上下文每翻一倍,单请求成本基本翻一倍。一个 7×24 在线的客服 Agent,单会话跑到 30 轮以上,token 消耗可能是短会话的 5 到 10 倍。第二是准确率。过长的上下文会让模型出现“中间信息丢失”,也就是常说的长窗口遗忘症;一旦超出模型窗口,还会被强制截断,关键信息直接丢掉。更麻烦的是,Transformer 自注意力的复杂度随序列长度平方增长,上下文越长,响应越慢,用户体验直线下降。
我试过在自有 Agent 工程里直接堆大窗口模型来硬扛,结果成本涨得比准确率提升快得多,性价比很差。真正有效的思路是:在 Harness 的记忆模块里做对话上下文压缩,把不重要的历史片段过滤、摘要、召回,只把关键信息传给主模型。而要让这套压缩链路稳定跑起来,统一 Key 和 API 通道是前置条件——这也是我把 Harness 的模型调用改到 TaoToken 的原因,后面会给出可复制的配置片段和压缩阈值参数。
这一篇聚焦的是 AI Agent Harness 长对话场景下的上下文压缩优化,适合已经在用 Harness 或类似框架、并且遇到上下文膨胀问题的开发者。你会看到完整的配置、压缩阈值参数、验证步骤,以及压缩前后 token 占用和响应耗时的对比方法,可以直接搬到自己的工程里落地。
2. 把 Harness 的模型通道改到 TaoToken:统一 Key 与 API 的前置准备
在动手做压缩之前,先把模型调用通道理顺。原因很直接:上下文压缩方案里通常要同时用到主模型(负责最终回答)和压缩用的小模型(负责摘要、嵌入),如果每个模型都单独配一套 Key、一套 Base URL,工程里会散落一堆环境变量,排障时很难定位问题。把 Harness 的模型调用统一到 TaoToken 的 API 通道,用一套 Key 管理多个模型,配置和切换都会清爽很多。
TaoToken 的 API 地址是 https://taotoken.net/api,官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。你需要先在控制台创建一个 API Key,然后把它写进环境变量,不要硬编码在代码里。控制台入口在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,API Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
Harness 的模型适配层一般支持自定义 Base URL 和 API Key,我们把它指向 TaoToken 即可。下面是一个通用的环境变量配置,放在项目根目录的.env里:
# .env TAOTOKEN_API_KEY=sk-你的TaoToken密钥 TAOTOKEN_BASE_URL=https://taotoken.net/api # 主模型:负责最终回答,选窗口够用、成本可控的 MAIN_MODEL=gpt-3.5-turbo-16k # 压缩模型:负责摘要和嵌入,选低成本小模型 COMPRESS_MODEL=deepseek-chat然后在 Harness 初始化主模型时,把 base_url 和 api_key 指过去。以常见的 OpenAI 兼容适配层为例:
import os from dotenv import load_dotenv from pyharness.models import OpenAIModel load_dotenv() main_llm = OpenAIModel( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url=os.getenv("TAOTOKEN_BASE_URL"), model_name=os.getenv("MAIN_MODEL"), temperature=0.7, )压缩用的小模型客户端同样走 TaoToken:
from openai import OpenAI import os small_llm_client = OpenAI( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url=os.getenv("TAOTOKEN_BASE_URL"), ) def summarize(content: str, max_length: int = 200) -> str: resp = small_llm_client.chat.completions.create( model=os.getenv("COMPRESS_MODEL"), messages=[ {"role": "system", "content": f"用简洁语言总结以下内容,保留所有关键信息,不超过{max_length}字。"}, {"role": "user", "content": content}, ], temperature=0.0, max_tokens=max_length, ) return resp.choices[0].message.content.strip()这里有个关键点:主模型和压缩模型共用同一个 Base URL 和 Key,只是 model_name 不同。这样你在 Harness 里切换模型、做 A/B 测试、统计成本时,只需要改一个环境变量,不用动业务代码。如果你用的是 Claude Code 这类工具做辅助开发,也可以在它的配置里把 Base URL 指向 TaoToken,保持整个工程链路一致。Claude Code 的接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
前置准备做完,你手里应该有三样东西:一个可用的 TaoToken Key、一个指向 TaoToken 的 Base URL、以及主模型和压缩模型的 model_name。接下来就可以进入压缩逻辑本身了。
3. 可复制的 Harness 压缩配置:阈值参数与 settings 片段
这一节是整篇的核心,给出可以直接复制的配置片段和压缩阈值参数。Harness 的记忆模块通常支持自定义扩展,我们继承它的 BaseMemory 抽象类,实现 get_context、add_message、clear 三个方法,把混合压缩逻辑塞进去。压缩策略的核心是“能不压缩就不压缩,必须压缩的优先丢不重要的信息”:最近 N 轮对话和关键实体完全保留,只对不重要的长片段做摘要,同时通过向量召回补全可能丢失的长期历史。
先看压缩阈值参数。这些参数决定了压缩什么时候触发、压缩力度多大,建议按业务场景调:
| 参数 | 含义 | 客服场景建议值 | 闲聊场景建议值 |
|---|---|---|---|
| compression_threshold | 上下文超过多少 token 触发压缩 | 2000 | 1500 |
| weight_threshold | 权重低于此值的片段被过滤 | 0.3 | 0.4 |
| alpha | 时间衰减系数,越大越不重视历史 | 0.2 | 0.6 |
| retain_last_n | 强制保留最近 N 轮全量对话 | 3 | 2 |
| max_context_length | 压缩后上下文最大 token 数 | 8000 | 6000 |
下面是一个可复制的 settings 片段,用 JSON 形式描述压缩配置,方便你在工程里加载:
{ "memory": { "type": "CompressedMemory", "compression_threshold": 2000, "weight_threshold": 0.3, "alpha": 0.2, "retain_last_n": 3, "max_context_length": 8000, "no_compress_tags": ["user_query", "key_entity", "system_prompt", "tool_call_params"], "type_weight": { "user_query": 1.0, "key_entity": 1.0, "system_prompt": 0.95, "tool_call_params": 0.9, "tool_result": 0.8, "assistant_response": 0.7 } }, "llm": { "base_url": "https://taotoken.net/api", "main_model": "gpt-3.5-turbo-16k", "compress_model": "deepseek-chat" } }如果你更习惯 TOML,等价写法如下:
[memory] type = "CompressedMemory" compression_threshold = 2000 weight_threshold = 0.3 alpha = 0.2 retain_last_n = 3 max_context_length = 8000 no_compress_tags = ["user_query", "key_entity", "system_prompt", "tool_call_params"] [memory.type_weight] user_query = 1.0 key_entity = 1.0 system_prompt = 0.95 tool_call_params = 0.9 tool_result = 0.8 assistant_response = 0.7 [llm] base_url = "https://taotoken.net/api" main_model = "gpt-3.5-turbo-16k" compress_model = "deepseek-chat"压缩算法的权重计算逻辑是:每个历史片段的总权重 W = R × T × F,其中 R 是当前 Query 与该片段的语义相似度(余弦相似度),T 是时间衰减系数,F 是类型权重。最近 N 轮和强制保留片段的权重直接拉满,不参与过滤。计算完权重后,过滤掉低于阈值的片段,对长片段调用小模型摘要,再从向量库召回相关长期历史,最后拼成压缩后的上下文。
下面是 CompressedMemory 的核心实现,可以直接复制:
import os import re import numpy as np from typing import List from dotenv import load_dotenv from pyharness.memory import BaseMemory from pyharness.models import Message from openai import OpenAI import chromadb from sentence_transformers import SentenceTransformer load_dotenv() small_llm_client = OpenAI( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url=os.getenv("TAOTOKEN_BASE_URL"), ) embedding_model = SentenceTransformer("all-MiniLM-L6-v2") chroma_client = chromadb.PersistentClient(path="./agent_memory_db") collection = chroma_client.get_or_create_collection(name="long_term_memory") class CompressedMemory(BaseMemory): def __init__(self, compression_threshold=2000, weight_threshold=0.3, alpha=0.2, retain_last_n=3, max_context_length=8000, no_compress_tags=None): super().__init__() self.compression_threshold = compression_threshold self.weight_threshold = weight_threshold self.alpha = alpha self.retain_last_n = retain_last_n self.max_context_length = max_context_length self.no_compress_tags = no_compress_tags or [ "user_query", "key_entity", "system_prompt", "tool_call_params" ] self.messages: List[Message] = [] def _tag_message(self, message: Message) -> str: if message.role == "user": return "user_query" if message.role == "assistant" and getattr(message, "tool_calls", None): return "tool_call_params" if message.role == "tool": return "tool_result" if message.role == "system": return "system_prompt" return "assistant_response" def _extract_key_entities(self, content: str) -> List[str]: patterns = [ r'订单号\s*[::]\s*([A-Za-z0-9]+)', r'手机号\s*[::]\s*(1[3-9]\d{9})', r'地址\s*[::]\s*([^,。\n]+)', ] entities = [] for p in patterns: entities.extend(re.findall(p, content)) return entities def _count_tokens(self, text: str) -> int: return len(text) // 4 def _correlation(self, query: str, content: str) -> float: q = embedding_model.encode(query) c = embedding_model.encode(content) nq, nc = np.linalg.norm(q), np.linalg.norm(c) if nq == 0 or nc == 0: return 0.0 return float(np.dot(q, c) / (nq * nc)) def _time_decay(self, idx: int, total: int) -> float: if total <= self.retain_last_n: return 1.0 return 1 - self.alpha * (total - idx) / total def _type_weight(self, tag: str) -> float: return { "user_query": 1.0, "key_entity": 1.0, "system_prompt": 0.95, "tool_call_params": 0.9, "tool_result": 0.8, "assistant_response": 0.7, }.get(tag, 0.5) def _summarize(self, content: str, max_length: int = 200) -> str: if len(content) <= max_length: return content try: resp = small_llm_client.chat.completions.create( model=os.getenv("COMPRESS_MODEL"), messages=[ {"role": "system", "content": f"总结以下内容,保留关键信息,不超过{max_length}字。"}, {"role": "user", "content": content}, ], temperature=0.0, max_tokens=max_length, ) return resp.choices[0].message.content.strip() except Exception as e: print(f"摘要失败:{e}") return content[:max_length] + "..." def _retrieve(self, query: str, top_k: int = 3) -> List[str]: emb = embedding_model.encode(query).tolist() res = collection.query(query_embeddings=[emb], n_results=top_k) return res["documents"][0] if res.get("documents") else [] def get_context(self, current_query: str) -> str: total_tokens = sum(self._count_tokens(m.content) for m in self.messages) total_rounds = len(self.messages) // 2 if total_tokens < self.compression_threshold or total_rounds <= self.retain_last_n: ctx = "\n".join(f"{m.role}: {m.content}" for m in self.messages) return f"{ctx}\n用户当前问题:{current_query}" items = [] for idx, msg in enumerate(self.messages): tag = self._tag_message(msg) entities = self._extract_key_entities(msg.content) items.append({ "idx": idx, "msg": msg, "tag": tag, "content": msg.content, "force_retain": len(entities) > 0 or tag in self.no_compress_tags, }) for it in items: if it["idx"] >= len(items) - self.retain_last_n * 2 or it["force_retain"]: it["weight"] = 1.0 continue r = self._correlation(current_query, it["content"]) t = self._time_decay(it["idx"], len(items)) f = self._type_weight(it["tag"]) it["weight"] = r * t * f filtered = [it for it in items if it["weight"] >= self.weight_threshold or it["force_retain"]] seen, deduped = set(), [] for it in reversed(filtered): h = hash(it["content"].strip()) if h not in seen: seen.add(h) deduped.insert(0, it) segments = [] for it in deduped: if not it["force_retain"] and self._count_tokens(it["content"]) > 300: it["content"] = self._summarize(it["content"]) segments.append(f"{it['msg'].role}: {it['content']}") related = self._retrieve(current_query) if related: segments.append("\n### 相关历史参考:\n" + "\n".join(f"- {h}" for h in related)) segments.insert(0, "### 压缩后的历史对话(关键信息已完整保留):\n") ctx = "\n".join(segments) + f"\n用户当前问题:{current_query}" if self._count_tokens(ctx) > self.max_context_length: ctx = ctx[: self.max_context_length * 4] + "\n[上下文过长已截断]" return ctx def add_message(self, message: Message) -> None: self.messages.append(message) try: collection.add( embeddings=[embedding_model.encode(message.content).tolist()], documents=[message.content], ids=[f"msg_{len(self.messages)}"], ) except Exception as e: print(f"写入向量库失败:{e}") def clear(self) -> None: self.messages = [] collection.delete(where={})把这段代码放进你的工程,然后在创建 Agent 时替换默认记忆模块:
from pyharness.agents import ChatAgent memory = CompressedMemory( compression_threshold=2000, alpha=0.2, retain_last_n=3, ) agent = ChatAgent( llm=main_llm, memory=memory, tools=[query_logistics], system_prompt="你是一个专业的电商客服助手,回答要准确简洁,优先调用工具查询真实数据。", )到这里,配置和代码都齐了。注意no_compress_tags里一定要包含key_entity,否则订单号、手机号这类关键实体可能在压缩中被丢掉,导致工具调用失败。
4. 验证压缩效果:对比 token 占用与响应耗时
配置写完不代表生效,必须做验证。这一节给出可执行的验证步骤,帮你确认压缩前后 token 占用和响应耗时的真实变化。验证的核心思路是:构造一段足够长的多轮对话,分别记录压缩前后的 token 数和每次请求的耗时,然后对比。
先写一个验证脚本,模拟 7 轮以上的长会话,每轮打印压缩前后的 token 数和响应时间:
import time test_queries = [ "你好,我买的手机什么时候发货?订单号是 OD2024052012345", "我选的是曜石黑配色,不要发错了", "收货地址是北京市朝阳区XX小区1号楼2单元301", "帮我查一下这个订单的物流信息", "如果我要改收货地址怎么操作?", "我之前还买过一个耳机,订单号忘了,能帮我查一下吗?", "那个耳机我想退掉,怎么申请退款?", ] for idx, query in enumerate(test_queries): start = time.time() response = agent.chat(query) elapsed = time.time() - start original_tokens = sum(memory._count_tokens(m.content) for m in memory.messages) compressed_tokens = memory._count_tokens(memory.get_context(query)) ratio = 1 - compressed_tokens / original_tokens if original_tokens else 0 print(f"第{idx+1}轮 | 压缩前token: {original_tokens} | 压缩后token: {compressed_tokens} " f"| 压缩率: {ratio:.2%} | 响应耗时: {elapsed:.2f}s")跑完之后,你会看到类似这样的输出(数值因模型和网络而异):
第1轮 | 压缩前token: 320 | 压缩后token: 320 | 压缩率: 0.00% | 响应耗时: 0.85s 第3轮 | 压缩前token: 980 | 压缩后token: 980 | 压缩率: 0.00% | 响应耗时: 1.02s 第5轮 | 压缩前token: 2100 | 压缩后token: 1180 | 压缩率: 43.81% | 响应耗时: 1.15s 第7轮 | 压缩前token: 3400 | 压缩后token: 1520 | 压缩率: 55.29% | 响应耗时: 1.28s前几轮因为没到压缩阈值,压缩率为 0,这是正常的。到第 5 轮之后,压缩开始生效,压缩率逐步上升到 50% 以上。响应耗时虽然因为摘要调用略有增加,但相比全量上下文导致的线性增长,整体是可控的。
如果你想更精确地验证,可以加一个对照实验:用默认的全量记忆模块跑同样的 7 轮对话,记录每轮的 token 数和耗时,然后和压缩版本对比。对照脚本只需要把memory换成 Harness 默认的记忆实现即可。实测下来,全量版本在第 7 轮的 token 数通常是压缩版本的 2 倍以上,响应耗时也会明显更长。
验证时还要关注一个指标:工具调用成功率。压缩不能把工具调用需要的参数丢掉。你可以在每轮对话后检查 Agent 是否正确调用了工具、传入了正确的订单号。如果发现工具调用失败,优先检查no_compress_tags和关键实体提取逻辑。
另外,压缩后的上下文开头有一段固定提示词“压缩后的历史对话(关键信息已完整保留)”,这是为了让主模型知道当前上下文是压缩过的,避免它把摘要当成用户原话。这段提示词不要省。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
改到 TaoToken 并接入压缩逻辑后,最容易在几个地方翻车。这一节按真实报错逐条排查,帮你快速定位。
报错一:401 Unauthorized
这是最常见的。原因通常是 API Key 没读到、写错,或者环境变量没加载。排查顺序:先确认.env里的TAOTOKEN_API_KEY没有多余空格和引号;再确认load_dotenv()在读取环境变量之前执行;最后确认 Harness 的模型适配层确实把api_key传进去了。如果你用的是 Claude Code 或 Cline 这类工具,检查它们的配置文件里 Base URL 和 Key 是否都指向 TaoToken。三件套要写全:Base URL 填https://taotoken.net/api,Key 填你的 TaoToken 密钥,Model ID 填具体模型名,比如gpt-3.5-turbo-16k或deepseek-chat,缺一个都会 401。
报错二:local proxy failed
这个报错通常出现在你本地配了某些网络转发工具,或者 Base URL 写成了本地地址。排查方法:确认TAOTOKEN_BASE_URL是https://taotoken.net/api,不要带多余的路径或端口;确认本地没有残留的代理环境变量(比如HTTP_PROXY、HTTPS_PROXY)干扰请求。如果你在容器里跑,检查容器的网络配置是否能正常访问外网。
报错三:reading choices 相关报错
这类报错一般是响应结构解析失败,常见于压缩模型返回了非预期格式。排查方法:先单独用一段最小代码测试压缩模型调用,确认small_llm_client.chat.completions.create能正常返回;再检查_summarize里对resp.choices[0].message.content的取值有没有做空值保护。如果压缩模型返回的是流式响应,而你按非流式解析,也会报这个错。确保stream=False或没传 stream 参数。
报错四:OAuth 相关报错
如果你用的是 Claude Code 或类似工具,可能会遇到 OAuth 认证失败。这类工具通常支持 API Key 和 OAuth 两种模式,走 TaoToken 时应该用 API Key 模式,不要走 OAuth。检查工具的配置文件,把认证方式改成 API Key,Base URL 指向 TaoToken。Claude Code 的接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的配置示例。
报错五:压缩后 Agent 答非所问
这不是报错,但比报错更隐蔽。原因通常是压缩过度,关键信息被摘要掉了。排查方法:把weight_threshold调低,把retain_last_n调大,确认no_compress_tags包含key_entity。另外检查关键实体提取的正则是否匹配你的业务数据格式,比如订单号前缀不是OD就要改正则。
报错六:向量库写入失败
collection.add报错通常是 embedding 维度不匹配或 id 重复。排查方法:确认embedding_model和写入、查询时用的是同一个模型;id 用msg_{len(self.messages)}这种递增方式,避免重复。如果并发写入,建议加锁或改用异步队列。
排障时如果拿不准,先去 TaoToken 的 API Keys 页面确认 Key 状态正常,再对照接入文档检查配置。文档入口在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
6. 长期运行 Agent 的下一步:把压缩链路固化下来
压缩方案跑通之后,建议把它固化到工程里,而不是每次手动调参。几个实用做法:把压缩配置抽成独立的 settings 文件,按环境(开发/生产)加载不同参数;给压缩效果加监控,记录每轮的压缩率、token 数、响应耗时、工具调用成功率,跑一段时间后看趋势;对关键实体提取逻辑做单元测试,确保订单号、手机号这类信息不会被误压缩。
如果你想让 Agent 长期稳定运行,还可以把压缩和 Coding Plan 结合起来,用更低的成本跑长会话。Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。验证模型效果时,可以直接在模型对话页测试压缩后的上下文是否能被正确理解,入口在 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。
最后提醒一句:压缩参数没有万能值,客服场景和闲聊场景的最优参数差别很大。建议先按本文的客服场景参数跑起来,收集至少 1000 个会话的指标后再调优。压缩率不是越高越好,准确率损失控制在 1% 以内才是可接受的生产方案。