Turn Loop 与 Streaming Round 深度解析
这一页只拆公共 turn runtime:如何找到最新用户输入、压缩历史、边收 stream 边合成 tool_use,并在中断后 salvage 成可继续处理的事件序列。
导入链与契约
turn.py 的入口并不复杂,但职责很集中:sources/commerce-agents/commerce-common/commerce_common/turn.py:67、sources/commerce-agents/commerce-common/commerce_common/turn.py:87、sources/commerce-agents/commerce-common/commerce_common/turn.py:110、sources/commerce-agents/commerce-common/commerce_common/turn.py:124 先定义本轮上下文怎么从历史里取出来,再把后续流式事件放进一个可以被 executor 消费的结构。
67 def latest_user_text(messages: list[dict[str, Any]], host_texts: Collection[str] = ()) -> str: 68 """The most recent user-authored text; "" when the conversation has none.""" 69 for message in reversed(messages): 70 if message.get("role") != "user": 71 continue 72 content = message.get("content") 73 texts = _text_blocks(content) 74 if texts and all(text in host_texts for text in texts): 75 continue 76 if texts: 77 return "\n".join(texts) 78 # A message of tool results is the loop's own; anything else ends the search. 79 if isinstance(content, list) and any( 80 isinstance(block, dict) and block.get("type") == "tool_result" for block in content 81 ): 82 continue 83 return "" 84 return "" 85 86 87 def latest_exchange( 88 messages: list[dict[str, Any]], host_texts: Collection[str] = () 89 ) -> list[dict[str, Any]]: 90 """The slice from the most recent user-authored message onward: what one turn's 91 extraction pass reads, so earlier turns are not re-extracted in new words.""" 92 for index in range(len(messages) - 1, -1, -1): 93 if _is_user_text(messages[index], host_texts): 94 return messages[index:] 95 return messages
核心实现,逐段拆
▸EagerDispatcher 让工具调用提前执行
dispatcher 观察 tool_use block 的 JSON 参数,一旦满足 contract 就提前 dispatch;sources/commerce-agents/commerce-common/commerce_common/turn.py:174、sources/commerce-agents/commerce-common/commerce_common/turn.py:186、sources/commerce-agents/commerce-common/commerce_common/turn.py:199、sources/commerce-agents/commerce-common/commerce_common/turn.py:214、sources/commerce-agents/commerce-common/commerce_common/execution.py:214 说明它既追求低延迟,也保留了失败时返回到普通 round 处理的空间。
174 class EagerDispatcher: 175 """Tool execution started mid-stream. When a ``tool_use`` block's buffered JSON 176 parses at ``content_block_stop``, the call's arguments are final — the loop starts 177 executing then, while the model writes the rest of the round, so tool time overlaps 178 generation time. ``collect`` is the post-stream join: started calls are awaited, 179 anything the stream path missed (unparseable buffer, dispatch off) executes from the 180 final message's canonical args, and every call runs exactly once. ``cancel`` is the 181 error and malformed-stream backstop; a started task must not outlive its turn.""" 182 183 def __init__( 184 self, execute: Callable[[str, dict[str, Any]], Awaitable[ToolOutcome]], enabled: bool 185 ) -> None: 186 self._execute = execute 187 self._enabled = enabled 188 self._tasks: dict[str, asyncio.Future[ToolOutcome]] = {} 189 190 def started(self, tool_use_id: str) -> bool: 191 return tool_use_id in self._tasks 192 193 def dispatch(self, name: str, tool_use_id: str, args: dict[str, Any] | None) -> bool: 194 """Start ``name`` on ``args``; False when it did not start (dispatch off, already 195 started, or no parsed args) and the join executes it later.""" 196 if not self._enabled or args is None or tool_use_id in self._tasks: 197 return False 198 self._tasks[tool_use_id] = asyncio.ensure_future(self._execute(name, args)) 199 return True 200 201 def settle(self, tool_use_id: str, outcome: ToolOutcome) -> None: 202 """Give ``tool_use_id`` its outcome without executing it (a call whose input 203 never parsed); the join returns it in place. A started call keeps its task.""" 204 if tool_use_id in self._tasks: 205 return 206 settled: asyncio.Future[ToolOutcome] = asyncio.get_running_loop().create_future() 207 settled.set_result(outcome) 208 self._tasks[tool_use_id] = settled 209 210 async def collect(self, tool_uses: list[Any]) -> list[ToolOutcome]: 211 return await asyncio.gather( 212 *( 213 self._tasks[block.id] 214 if block.id in self._tasks 215 else self._execute(block.name, dict(block.input or {})) 216 for block in tool_uses 217 ) 218 ) 219 220 def cancel(self) -> None:
▸salvage 与 close_open_tool_uses 兜住半截 stream
当模型输出停在 tool_use 半路,runtime 不能把会话留在不一致状态;sources/commerce-agents/commerce-common/commerce_common/turn.py:379、sources/commerce-agents/commerce-common/commerce_common/turn.py:401、sources/commerce-agents/commerce-common/commerce_common/turn.py:501、sources/commerce-agents/commerce-common/commerce_common/turn.py:520、sources/commerce-agents/commerce-common/commerce_common/turn.py:544 把 partial round 转成可记录事件,再补齐未关闭工具调用的结果。
401 def salvage_round( 402 streamed: StreamedRound, 403 dispatcher: EagerDispatcher, 404 caller: logging.Logger, 405 session_id: str | None, 406 round_index: int, 407 ) -> tuple[dict[str, Any] | None, list[Any], set[str]]: 408 """:meth:`StreamedRound.salvaged` for a round the SDK's accumulator abandoned, with 409 each unreadable client call settled on ``dispatcher`` as an error result so the model 410 sends it again, and a WARNING per unreadable call on ``caller`` naming it and the size 411 of what arrived, never the input.""" 412 reply, tool_uses, unreadable = streamed.salvaged() 413 for tool in streamed.tools.values(): 414 if tool.id not in unreadable: 415 continue 416 caller.warning( 417 "tool input unreadable session=%s round=%d tool=%s id=%s chars=%d", 418 session_tag(session_id), 419 round_index, 420 tool.name, 421 tool.id, 422 len(tool.buffer), 423 stacklevel=2, 424 ) 425 if not tool.server: 426 dispatcher.settle(tool.id, ToolOutcome.error(UNREADABLE_INPUT_TEXT)) 427 return reply, tool_uses, unreadable
关键流程
完整流程由历史提取、stream feed、frame/relay、outcome event 和关闭工具调用组成;这些节点分布在 sources/commerce-agents/commerce-common/commerce_common/turn.py:124、sources/commerce-agents/commerce-common/commerce_common/turn.py:270、sources/commerce-agents/commerce-common/commerce_common/turn.py:326、sources/commerce-agents/commerce-common/commerce_common/turn.py:344、sources/commerce-agents/commerce-common/commerce_common/turn.py:501、sources/commerce-agents/commerce-common/tests/test_turn.py:77,测试直接覆盖了文本输出、工具结果和中断恢复。
边界 / 失败模式
- 如果 partial JSON 不能被解析,sources/commerce-agents/commerce-common/commerce_common/turn.py:379 会保留原始输入而不是伪造参数。
- 如果 stop_reason 已经关闭一轮,sources/commerce-agents/commerce-common/commerce_common/turn.py:520 会影响是否继续追加工具结果。
- sources/commerce-agents/commerce-common/tests/test_turn.py:147 和 sources/commerce-agents/commerce-common/tests/test_turn.py:191 覆盖了 salvage 与未关闭 tool_use。
▸测试如何固定 turn loop 语义
turn loop 的测试价值在于覆盖异常形态,而不是只测一条完整文本回复;sources/commerce-agents/commerce-common/tests/test_turn.py:42、sources/commerce-agents/commerce-common/tests/test_turn.py:106 与 sources/commerce-agents/commerce-common/tests/test_turn.py:220 分别把最新输入、tool_use 聚合和关闭动作固定下来。