Skip to content

turn_context 与提示组装

源码版本v2026.7.20

职责

在每一轮 API 调用前,把「历史消息 + 本轮用户输入 + 网关侧附加上下文 + 压缩标记」装配成发给 provider 的 api_messagesTurnContext 是这轮产物的容器,build_turn_context 是装配入口。它还负责把网关收集的笔记(notes)注入到多模态内容里。

关键文件

数据流

  1. 主循环每轮调用 build_turn_context:268,得到一个 TurnContext
  2. compose_user_api_content(agent/turn_context.py:44) 把用户原始输入拼成 API 内容。
  3. 若网关侧累积了 notes(来自附件、上下文提示),consume_gateway_turn_context_notes(agent/turn_context.py:124)取出并经 append_notes_to_multimodal_content 注入。
  4. substitute_api_content(agent/turn_context.py:79)替换占位符;drop_stale_api_content 清理过期内容。
  5. 若上一轮发生过压缩,reanchor_current_turn_user_idx(agent/turn_context.py:164)修正本轮用户消息在历史中的位置。
  6. 装配好的 api_messages 进入 API 调用重试子循环:1227

TurnContext 是普通 dataclass,把 prologue 算出来的值捆成一份(关键字段见 agent/turn_context.py:242-268):user_message 是消毒后的本轮消息;original_user_message 保留原始文本用于记忆与日志查询(nudge 不会注入进去);conversation_history 可能被 preflight 压缩置 None——压缩会开新会话,旧 history 不能再用;active_system_prompt 是本轮缓存的系统提示,也可能被压缩重建。

网关 notes 的消费逻辑见 agent/turn_context.py:124-145:

python
def consume_gateway_turn_context_notes(agent: Any) -> str:
    """Pop the gateway's per-turn must-deliver notes off the agent (one-shot).
    ... so the composed system prompt stays byte-stable turn-over-turn.
    ... this consumes them so a cached agent can never replay a stale note."""
    notes = getattr(agent, "_gateway_turn_context_notes", "") or ""
    if hasattr(agent, "_gateway_turn_context_notes"):
        try:
            agent._gateway_turn_context_notes = ""
        except Exception:
            pass
    return notes if isinstance(notes, str) else ""

取完立即清零——这是个一次性 pop,不是 peek。即便 agent 被缓存复用,上一轮没消费完的 notes 也不会泄漏到下一轮。

设计动机

为什么把 prologue 抽成 build_turn_context?原来所有「每轮一次」的准备(stdio 守卫、retry 计数器重置、user message 消毒、todo/nudge 注入、系统提示构建、preflight 压缩、pre_llm_call 钩子、外部记忆预取、崩溃恢复持久化)都内联在 run_conversation 开头,接近 130 行。抽成函数后,主循环只剩「循环 + 重试」,读主循环时不必被准备逻辑打断;prologue 还可以单独测试,subagent 路径也能复用。

为什么 notes 走用户消息旁路而不是塞进 system prompt?系统提示在多轮之间需要 byte 稳定——一旦改变,前面缓存的 prefix 就失效,provider 侧的 prompt caching 直接报废。网关把易变内容(首次自我介绍、语音通道切换、自动 reset 的临时提示)从 system prompt 里挪出来,通过用户消息的 api_content sidecar 投递,既让每轮都能带上新事实,又保住了缓存命中率。

_compression_made_progress 用 5% 作为 material 阈值,是为了防多轮压缩空转:某轮把 220 条消息压成 220 条但 token 从 288k 降到 183k,按行数判断会误报「压不动了」然后触发 auto-reset。

边界与失败

  • 多模态消息丢失 notes:compose_user_api_content 对非字符串内容返回 None,用户这轮发图片时 sidecar 形式的 notes 会静默丢失。append_notes_to_multimodal_content 兜底把 notes 作为 text part 直接 append,代价是这部分内容会被持久化进 transcript——网关侧必须只放真正需要落盘的内容。
  • 压缩后用户消息索引漂移:压缩把旧消息合成 summary,本轮用户消息在 messages 里的位置会变。reanchor_current_turn_user_idx 重新扫一遍 list 找到下标;不重定位会让工具调用结果回填找错位置。
  • 少量但超大消息:_should_run_preflight_estimate 旧版只看消息条数,几条巨型 base64 图片永远不会触发压缩,直接撞 hard overflow(#27405)。新版加了 char-based 估算分支,大消息也能命中阈值。
  • stale notes 残留:consume_gateway_turn_context_notes 用 try/except 兜住写入失败——即便 agent 属性是只读 property,也不会因清零失败而中断装配。

小结

turn_context 是「每轮的快照工厂」:把动态上下文固化为一个可发送的结构。它和上下文压缩协同——压缩改写历史后,turn_context 负责把指针重新对齐。

非官方社区学习站,内容以 MIT 许可的 NousResearch/hermes-agent 源码为依据。