涉及源码 :
src/query_engine.py、src/runtime.py、src/main.py;Rustrust/crates/tools/src/lib.rs(StructuredOutput工具);对照rust/crates/claw-cli/src/app.rs(OutputFormat,与 Python 开关不同名同源问题)。
1. 术语:仓库里至少有三条「结构化」线
| 机制 | 位置 | 与 structured_output 的关系 |
|---|---|---|
QueryEngineConfig.structured_output |
Python query_engine |
本文主角 :决定 port 的 TurnResult.output 是 多行文本 还是 整段缩进 JSON 字符串 |
StructuredOutput 工具 |
Rust tools |
无关:模型通过工具调用提交任意 JSON 对象,执行器原样包进结果 JSON |
OutputFormat::Json / Ndjson |
Rust claw-cli app.rs |
无关:REPL/CLI 对人类可读回复再包一层 JSON 行 |
下面分述主角与旁支,避免读源码时混为一谈。
2. Python:structured_output 如何改变可解析性
2.1 配置项
python
# 15:21:src/query_engine.py
@dataclass(frozen=True)
class QueryEngineConfig:
max_turns: int = 8
max_budget_tokens: int = 2000
compact_after_turns: int = 12
structured_output: bool = False
structured_retry_limit: int = 2
structured_output=False(默认) :submit_message里summary_lines用'\n'.join(...)拼成 多行纯文本 。对下游而言是 非 JSON :整段 stdout 不能json.loads一次吃干净(除非自行截取)。structured_output=True:同一组summary_lines被包进对象{"summary": [...], "session_id": "..."},再json.dumps(..., indent=2)→ 单文档合法 JSON 字符串(在序列化成功的前提下)。
python
# 152:159:src/query_engine.py
def _format_output(self, summary_lines: list[str]) -> str:
if self.config.structured_output:
payload = {
'summary': summary_lines,
'session_id': self.session_id,
}
return self._render_structured_output(payload)
return '\n'.join(summary_lines)
2.2 流式事件中的位置
stream_submit_message 在 message_delta 里输出的 text 就是 submit_message 算出的 result.output,因此开关 同时改变 delta 载荷的可解析性:
python
# 120:121:src/query_engine.py
result = self.submit_message(prompt, matched_commands, matched_tools, denied_tools)
yield {'type': 'message_delta', 'text': result.output}
message_stop 里的 usage / stop_reason 仍是结构化 dict(Python 字面量),与 structured_output 无关。
2.3 CLI 打印形态
turn-loop 子命令把 result.output 原样 print ,前面只加 markdown 风格标题;不会再包一层 JSON:
python
# 153:158:src/main.py
if args.command == 'turn-loop':
results = PortRuntime().run_turn_loop(args.prompt, limit=args.limit, max_turns=args.max_turns, structured_output=args.structured_output)
for idx, result in enumerate(results, start=1):
print(f'## Turn {idx}')
print(result.output)
print(f'stop_reason={result.stop_reason}')
因此:
- 开启
structured_output时,单轮 body 是一段可json.loads的字符串,但 整段 stdout 仍含## Turn 1、stop_reason=...------整体不是单一 JSON 。要做管道解析需 按节切片 或只解析print(result.output)那一段。 - 关闭时,body 为自然语言行,明确非 JSON。
3. 失败语义与重试:structured_retry_limit
3.1 重试只针对 json.dumps
python
# 161:169:src/query_engine.py
def _render_structured_output(self, payload: dict[str, object]) -> str:
last_error: Exception | None = None
for _ in range(self.config.structured_retry_limit):
try:
return json.dumps(payload, indent=2)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive branch
last_error = exc
payload = {'summary': ['structured output retry'], 'session_id': self.session_id}
raise RuntimeError('structured output rendering failed') from last_error
语义要点:
- 触发条件 :当前 payload 无法被
json.dumps(例如summary_lines里混入了不可 JSON 化的对象------在正常路径里summary_lines全是str,该分支标注为pragma: no cover,属防御性)。 - 重试行为 :不 重试同一 payload;而是 替换 为极小安全 payload(固定一句
'structured output retry'+session_id),再试,最多structured_retry_limit次。 - 最终失败 :抛出
RuntimeError('structured output rendering failed'),链上保留最后一次TypeError/ValueError。这是对 调用方 的硬失败;submit_message没有 try/except 包住_format_output,故异常会 冒泡 ,TurnResult不会生成。 - 与业务
stop_reason无关 :max_turns/max_budget_reached等仍走正常TurnResult;序列化失败 是 异常路径 ,不是stop_reason。
3.2 早退路径与可解析性不一致
在 max_turns 已达 时,output 是直接格式化的 英文句子 ,不 走 _format_output,因此 即使 structured_output=True,该轮也不会是 JSON:
python
# 68:77:src/query_engine.py
if len(self.mutable_messages) >= self.config.max_turns:
output = f'Max turns reached before processing prompt: {prompt}'
return TurnResult(
prompt=prompt,
output=output,
...
stop_reason='max_turns_reached',
)
结论 :structured_output 只保证「正常摘要路径」下的 body 形态;错误/早退字符串 仍可能破坏「每轮皆可 json.loads」的假设------企业若要做严格 JSON 流水线,需在下游 按 stop_reason 分支 或 统一包装 envelope。
4. Rust:StructuredOutput 工具(与配置开关无关)
工具定义允许 任意额外字段 (additionalProperties: true),执行器把输入 map echo 到 structured_output 字段:
rust
// 496:504:rust/crates/tools/src/lib.rs
ToolSpec {
name: "StructuredOutput",
description: "Return structured output in the requested format.",
input_schema: json!({
"type": "object",
"additionalProperties": true
}),
required_permission: PermissionMode::ReadOnly,
},
2536:2541:rust/crates/tools/src/lib.rs
fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
StructuredOutputResult {
data: String::from("Structured output provided successfully"),
structured_output: input.0,
}
}
序列化走 to_pretty_json → serde_json 失败则 Result::Err(String) ,没有 Python 那种「降级 payload + 多次重试」。失败语义是 工具调用失败,由上层 runtime/对话环处理。
这与 QueryEngineConfig.structured_output 完全独立:前者是 agent 工具链 的契约,后者是 Python port 的演示/测试输出格式。
5. 对照:CLI OutputFormat(Rust)
claw-cli 在写出 turn 结果时按 OutputFormat 选择文本或 JSON 行,例如:
rust
// 270:288:rust/crates/claw-cli/src/app.rs
match self.config.output_format {
OutputFormat::Text => {
writeln!(
out,
"\nToken usage: {} input / {} output",
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
)?;
}
OutputFormat::Json => {
writeln!(
out,
"{}",
serde_json::json!({
"message": summary.assistant_text,
"usage": {
"input_tokens": self.state.last_usage.input_tokens,
"output_tokens": self.state.last_usage.output_tokens,
}
})
)?;
}
这里 JSON 由代码构造 ,不依赖对模型输出做 json.dumps;失败主要来自 IO,而非「内容不可序列化」。与 Python port 的 structured_output + 重试 是不同问题域。
6. 小结表
| 维度 | structured_output=False |
structured_output=True |
|---|---|---|
| 可解析性 | 多行文本;无 JSON 保证 | 正常路径下 body 为 单文档 JSON 字符串 (summary + session_id) |
流式 message_delta |
文本行 | 同上整段 JSON 字符串 |
| 整页 stdout(turn-loop) | 非纯 JSON | 仍含标题与 stop_reason=,非单文件 JSON |
| 序列化失败 | 不适用 | 降级 payload 重试 structured_retry_limit 次,再失败 RuntimeError |
| max_turns 早退 | 纯文本句子 | 仍为纯文本,与开关不一致 |
budget stop_reason |
正常 JSON/文本 body 后仍带 max_budget_reached |
同左(body 形态仍由 _format_output 决定) |
设计启示 :若企业需要 稳定、可机器校验的每轮 envelope ,仅靠当前 structured_output 不够(早退、CLI 装饰行、混用测试)。更稳妥的是在 边界层 (HTTP/SSE、子进程协议)定义 固定顶层 JSON,把人类可读段落放进字段,而不是依赖「整段 print 即 JSON」。