长会话跑不动时,「上下文和凭证放哪边」通常不是二选一:对话上下文跟着会话走,按会话长度决定存在哪里、超出后怎么截;工具凭证跟着时间或调用次数走,按有效期决定什么时候去取。这两类数据如果混在一个地方、共用同一种刷新节奏,就容易出现「越跑越慢」和「工具中途鉴权失败」同时冒出来的情况。判断方向可以先落在调用记录上:请求体长度和返回历史条数对不上,优先查截断;工具调用在固定时刻或固定轮次附近开始报鉴权错误,优先查凭证有效期。
适用场景:Agent 会话跑到几十轮以上、并且会调用外部工具。操作动作:先把上下文与工具凭证分成两类,凭证改成调用工具前按需读取,上下文按会话长度单独设截断策略。验证方式:对比每轮的请求体长度、返回历史条数和工具返回码,看数值突变出现在哪一轮。风险边界:凭证刷新失败要给出可识别的返回,不能静默降级成空凭证;截断策略的改动会影响回答质量,建议先在小范围会话里观察。
在调用记录里确认上下文被截断的位置
先做一次排除:把「请求超时」和「历史被截断」分开。超时通常表现为连接层报错、拿不到完整响应体;截断通常表现为请求成功,但模型只看到了一部分历史。判断依据就在调用记录里——把本轮实际发给模型的请求体长度,和上一轮返回的历史条数放在一起看。
- 请求体长度:
request_bytes或prompt_tokens,两者取一个即可,但整篇口径要固定。 - 返回历史条数:
history_returned,指编排层拼进请求的 messages 条数,不是模型回答里可见的轮数。 - 轮次标识:
turn_index,从会话开始递增,用来定位「第几轮开始丢历史」。 - 截断原因:
truncate_reason,取值如none / window / summary / error;没有截断时也要落none,字段缺失容易被误读。
字段名可以按项目习惯改,但同一个会话里要保持稳定。日志是 ndjson 时,可以先按会话抽出来看趋势:
jq -c 'select(.session_id=="S-001") | {turn_index, prompt_tokens, history_returned, truncate_reason}' /var/log/agent/calls.ndjson
把输出按 turn_index 排序,重点看 history_returned 第一次不再随轮次增长的那一行,以及同一行的 truncate_reason。如果条数停住而 prompt_tokens 还在涨,说明历史被替换成了摘要而不是直接丢弃;如果两者同时停住,多半是滑动窗口在起作用。把这一轮的 turn_index 记下来,后面的复现和调参都以它为基准。
把对话上下文与工具凭证按生命周期分成两类
存放位置的判断依据只有一个:这份数据什么时候失效。失效方式不同,读取方和失效处理就不可能一样,硬凑成一套只会把问题推到更晚才暴露。
| 数据 | 失效方式 | 读取方 | 建议存放 | 失效处理 |
|---|---|---|---|---|
| 对话上下文 | 按会话长度或轮数失效,不按时间 | 编排层每轮拼请求前读取 | 会话级存储,按 session_id 索引 | 滑动窗口或摘要,保留系统提示与最近若干轮;截断原因落日志 |
| 工具凭证 | 按到期时间或调用次数失效 | 工具执行前即时读取,不由编排层整场持有 | 自有凭据存储,按工具加租户索引 | 能刷新就刷新,不能刷新就返回可识别错误并上抛 |
具体存放位置可以按环境调整,但有两个约束建议守住:上下文不要写进凭据存储,凭证也不要被编排层当成会话状态缓存整场。凭证一旦被塞进会话上下文,长会话往往要等凭证过期才暴露问题,而错误表现是工具返回鉴权失败,看起来却像上下文丢了。
改写凭证获取:调用工具前从自有存储读取
把工具凭证的获取收敛到一个函数,调用点放在工具执行之前,而不是 Agent 启动时。下面是通用骨架,存储访问方式按自己的环境替换,不依赖任何特定平台接口。
def get_credential(tool, tenant):
rec = credential_store.load(tool, tenant) # 自有存储或凭据服务
if rec is None:
log.warning('credential_missing', tool=tool, tenant=tenant)
return None
if rec.expires_at is not None and rec.expires_at <= now():
rec = credential_store.refresh(tool, tenant) # 刷新失败则抛异常
if rec is None:
log.warning('credential_refresh_failed', tool=tool)
return None
return rec.value
# 调用位置:工具执行前,不放在 Agent 初始化里
cred = get_credential(tool='search', tenant=tenant)
if cred is None:
return ToolResult(status='credential_unavailable', retryable=False)
resp = call_tool(tool='search', credential=cred, payload=payload)
几个取舍值得写死:刷新失败返回 None 而不是旧值,避免拿过期凭证反复打工具;credential_unavailable 这类返回值要能让编排层区分「不可重试」和「网络抖动可重试」,retryable 由调用方判断,不要在这一层直接重试整轮对话。凭证值不要写进对话上下文或普通调用日志,排查时只记 credential_id 和 expires_at。
连发多轮请求,记录每轮的上下文长度变化
固定一段输入,连续发若干轮,每轮把输入长度和返回历史条数记下来,数值突变的那一轮就是问题入口。下面的脚本只做记录、不做断言,输出落成 csv 方便逐行对比。
for i in $(seq 1 30); do
resp=$(call_agent `--session` "probe-$i" `--input` "$FIXED_INPUT")
line="$i,$(echo "$resp" | jq -r .prompt_tokens),$(echo "$resp" | jq -r .history_returned),$(echo "$resp" | jq -r .truncate_reason)"
echo "$line" >> probe.csv
done
# 列:turn_index,prompt_tokens,history_returned,truncate_reason
每次追加同一句话,不要每轮换内容,否则长度变化分不清是输入差异还是截断。跑完后按列扫一遍:history_returned 第一次持平或下降的那一行、prompt_tokens 增速明显放慢或跳变的那一行,都要单独标出来。把这一轮和服务端日志里的 turn_index 对齐,确认是窗口策略、摘要触发,还是请求被拒后回退。如果 30 轮内没有突变,可以加轮数再跑一遍,结论只对已经跑过的轮数范围成立。
把两类数据的存放位置写成配置项清单
把上面确认过的结论落成配置,交接时才不用靠口口相传。下面是一份可以照着改的清单,配置名按项目命名习惯替换,关键是「读写方」和「变更时同步修改的位置」要写清楚。
| 配置项 | 读写方 | 默认值来源 | 变更时需同步修改 |
|---|---|---|---|
context.store | 编排层读写,工具层只读不写 | 部署配置,默认指向会话存储 | 会话清理任务、session_id 的索引 |
context.max_turns | 编排层读 | 代码内默认值,可被环境变量覆盖 | 截断策略说明、探针脚本的轮数上限 |
context.truncate_policy | 编排层读 | 默认 window | truncate_reason 的取值枚举、日志解析脚本 |
credential.store | 工具调用层读写,编排层不接触 | 部署配置,默认指向凭据存储 | 刷新任务、权限配置、审计日志字段 |
credential.refresh_before | 工具调用层读 | 按自有存储的过期语义设定 | 刷新逻辑、告警阈值 |
credential.on_failure | 工具调用层读 | 默认 return_error | 工具返回码枚举、编排层的重试判断 |
提交前做一次自检:context.* 的变更是否只影响会话相关任务,credential.* 的变更是否只影响工具调用链路。任何一项要改到对方的目录,通常说明两类数据的边界还没分开,建议先回到调用记录里再看一轮,再动配置。