LLM 调用的真正挑战:确定性代码与概率系统的接口设计
这是《写一个 Coding Agent Harness and TUI 》系列的第 1 篇。 代码只有 200 行。但 200 行背后,是一个将定义后面 7 篇文章所有架构决策的元问题。
如果你已经写过调用 LLM API 的代码,这篇你可能想跳过。别跳。
调用 LLM API 的代码确实简单——reqwest POST + serde 解析,三十分钟搞定。但如果你只看到这一层,后面的工具系统、Turn Loop、TUI、安全模型——你会觉得每个都是"孤立的设计决策",散落一地,各不相干。
这篇要做的不是教你"怎么调 API"——是给你一张地图。 一张让你在读完 8 篇文章后回头看,能说出"哦,原来所有这些决策都在回答同一个问题"的地图。
那个问题是:确定性代码如何与概率系统安全地对齐?
〇、元问题:确定性 vs 概率性——Agent 开发的核心矛盾
两个世界,一套接口
Agent 的代码是确定性的。if x { do_a() } else { do_b() }。编译通过就能跑,跑起来的行为是可复现的。trace 一条执行路径,每个分支都能解释。
LLM 是概率性的。同样的 prompt、同样的温度、同样的模型——两次调用返回不同的结果。LLM 可能拒绝回答、可能编造信息、可能在 90% 的情况下正确解析你的 JSON 格式,但剩下 10% 返回一段自然语言解释。
Agent 的代码活在一个世界里,LLM 活在另一个世界里。它们之间的接口,就是你的整个调用层。
这不是一个"bug"——这是根本属性,不是缺陷
很多人在写 agent 时犯的第一个错误:把 LLM 的不确定性当成需要"修复"的问题。加更多的 prompt engineering、加格式校验、加 retry 逻辑——试图让 LLM 行为确定化。
这个方向是错的。 LLM 的价值恰恰在于它的非确定性——它能在没见过的问题上做出合理推断,能创造性地组合工具,能在错误路径上自我纠正。如果你把 LLM 驯化成确定性的,你就失去了 agent 90% 的能力。
真正的挑战不是消除不确定性——是设计一套接口,在确定性代码和概率性 LLM 之间建立安全的信息流动。
这个元问题的五个投影
后面的 8 篇文章,每一个架构决策都是这个元问题在不同层面的投影:
| 层面 | 问题 | 哪篇文章 |
|---|---|---|
| 数据格式 | LLM 的输出不可靠——JSON 可能格式错误,流式 chunk 可能乱序,tool_call 参数可能是幻觉 | 本篇(SSE 解析、消息结构) |
| 工具接口 | 工具必须容忍 LLM 传错参数——不能 panic,不能假设参数正确,返回的错误信息要帮 LLM 自纠正 | 第 2 篇(Tool trait 设计) |
| 控制流 | LLM 可能陷入循环——你不知道它几步能完成任务,也不知道它会不会无限循环 | 第 3 篇(Turn Loop 保护) |
| 编辑操作 | LLM 可能改错文件——你不知道它理解的是哪个函数,也不知道它的编辑范围有多大 | 第 4 篇(search_replace 唯一性) |
| 安全边界 | LLM 可能执行危险命令——你不知道它的意图是什么,也不知道它的"理解"是否正确 | 第 7 篇(权限模式) |
这不是五个独立问题。这是同一个问题穿了五件不同的衣服。
理解了这一点,再看本篇的四个具体决策,就不是"这里有个技术选型"——它们是确定性代码和概率性 LLM 之间接口设计的四个基本规则。
一、规则 1:接口层必须薄到能看穿
不是"选哪个库"——是"你敢不敢让这层变厚"
async-openai 是一个很好的库。两行代码:
let response = client.chat().create(request).await?;
println!("{}", response.choices[0].message.content);
问题不是它不好用——问题是它把 LLM 的不确定性藏起来了。
当你用 async-openai 时,你看到的是一条干净的 Rust 类型:CreateChatCompletionResponse。它的 choices[0].message.content 是一个 String。编译通过,类型安全,IDE 自动补全——一切看起来像是你熟悉的确定性编程。
但它不是。 content 的内容可能是一段格式错误的 JSON(当 LLM 决定返回 tool_call 但格式不对时)。finish_reason 可能是 "length"(被截断了),而你没注意到。tool_calls 数组里的 function.arguments 可能是一段不合法的 JSON 字符串。
当你用 async-openai 时,你意识不到这些。因为 SDK 帮你处理了 HTTP 传输、JSON 解析、类型转换——你把一个概率系统的输出,干净地映射到了确定性类型系统里。这个映射过程本身隐藏了所有风险信号。
什么时候可以加厚这层
当你已经完全理解了薄层的每一个细节之后。
grok-build 的做法就是这一原则的实例:它的内部(xai-grok-sampler)用了 async-openai,但它的开发者从裸 HTTP 开始理解了一切。然后在 SDK 之上加了重试策略、流控、认证刷新——这些是企业级需求的合理加厚。
不是永远不加抽象——是先不加,理解透,再加。 顺序不能反。反了,你就在一个你不理解的黑盒上面加了一层又一层的抽象——每一层都让你离 LLM 的真实行为更远一点,每一层都让 bug 更难追踪。
这就是为什么本系列始终先做薄实现、再讨论厚抽象。你现在写的每一个 struct、每一个 match、每一个
Option<String>,都在训练你的"LLM 不确定性直觉"。
根本原则
确定性系统与概率系统的接口层,厚度与可调试性成反比。 在你不完全理解概率系统的行为模式之前,接口层越薄越好。
二、规则 2:协议层面的"正常"不等于语义层面的"正确"
SSE 解析的三个坑——它们不是 bug,它们是协议设计时就注定的 mismatch
坑 1:chunk 跨行。SSE 协议说消息以 \n\n 分隔。TCP 不保证你一次 read() 刚好读到一个完整的 SSE 消息。一半的消息和下一个消息的前半段粘在一起——你收到的是"语法正确的字节流,语义不完整的帧"。
坑 2:delta.content 可能是 null。SSE 协议说每个 chunk 包含 choices[0].delta。但它没规定 delta 里有什么字段。当 LLM 决定调用工具时,delta 里有 tool_calls 没有 content。你的代码 chunk.choices[0].delta.content.unwrap() ——编译通过,运行时 panic。类型系统说它是 Option<String>,但你的代码假设它总是 Some。
坑 3:tool_calls 增量累积。SSE 协议说流式数据按顺序到达。但它没规定一个完整的 tool_call 是原子消息还是分片消息。实际上 LLM 第一个 chunk 发 tool_calls[0].function.name = "bash",第二 chunk 发 tool_calls[0].function.arguments = "{\"command\"",第三个发 ": \"ls\"}"。TCP 层面没有丢包,但应用层面的 tool_call 被拆成了三段。 如果你每个 chunk 都当成完整 tool_call 处理——你会创建 3 个 tool_call 对象,每个都不完整。
这不是解析器没写好
这三个坑有一个共同特征:每个坑都是"协议说 OK,但语义不对"。 TCP 没丢包。SSE 帧格式正确。JSON 语法合法。每一步单独的检查都通过——但组合起来,你的代码会产生错误结果。
这是概率性 LLM 和确定性协议之间的根本性 mismatch。 协议设计时假设的"消息" = 一条完整的数据单元。LLM 产生的"消息" = 在生成过程中逐步产出的数据流。协议把 LLM 的输出强行按"消息"切分——切出来的每个片段在协议层面合法,在语义层面不一定完整。
你的代码的任务不是"正确解析 SSE"——是在 SSE 协议的确定性框架内,处理 LLM 概率性产物的不确定性。
解法:维护一个"语义缓冲区"
不信任每一帧是完整语义单元。维护一个跨帧的 buffer,只在 semantic complete(消息完整)时才消费。具体代码在第 16 节(Turn Loop)实现——这里的关键不是实现,是建立"协议正确 ≠ 语义正确"的直觉。
grok-build 的做法:
SessionActor维护了一个ToolCallAccumulator,按 index 追踪每个 tool_call 的累积状态。不是"解析 SSE"——是"管理 LLM 输出的语义完整性"。
三、规则 3:概率系统的记忆管理不同于确定性系统的缓存管理
你以为是"数据结构选型",实际上是"工作记忆边界"问题
对话历史该用什么数据结构?这个问题看起来像 CS101 考试题——Vec vs VecDeque vs LinkedList。
它不是。
对话历史是 LLM 的工作记忆。它不是"缓存"(cache)——缓存可以丢掉,丢了再加载。它不是"日志"(log)——日志是完整记录,不需要选择性保留。它是 LLM 做出所有后续决策的信息基础。丢掉哪一部分,哪一部分就不存在于 LLM 的认知范围内。
滑动窗口的真正代价——不是丢数据,是丢"角色"
// 滑动窗口:保留最近 20 条消息。第 21 条进来时,最旧的 1 条被删除。
messages: VecDeque<Message>,
limit: 20,
假设 agent 已经运行了 18 轮工具调用。system prompt 在第 [0] 位,前 3 轮的工具结果是第 [1-6] 位。现在又来了一个新消息——第 [1] 位被弹出。被弹出的可能是一条 tool 角色的消息——LLM 再也看不到那次工具的执行结果。
它不"忘记"了一个对话细节。它忘记了自己的行为后果——它不知道自己二十分钟前执行过 cargo build,不知道那次 build 失败了因为依赖缺失,不知道它随后执行了 cargo add serde。
滑动窗口删掉的不只是一条消息。它删掉的是 agent 自我认知链条上的一环。
为什么 compaction 不是简单的"压 缩"
grok-build 的方案不是"删旧消息"——是"压缩旧消息"。压缩 = 把旧的对话历史总结成一段摘要文本,保留关键决策信息,释放 token 空间。
但这引入了新问题:谁来做压缩? 如果让 LLM 做——LLM 已经在上下文窗口紧张的边缘,你让它再加一个压缩任务。如果让另一个 LLM 做——增加了延迟和成本。
内存管理对确定性系统是技术问题(LRU、LFU、Paging)。对概率系统是认知问题(保留什么、丢弃什么、怎么丢弃而不会扭曲 agent 的决策能力)。
我们在第 3 篇(Token 保护)和第 8 篇(Memory 系统)回到这个问题——这里的关键不是我们选了 Vec 还是 VecDeque。关键是理解了这个数据结构不是"存消息的容器"——它是 agent 的"认知边界"。
当前决策
用 Vec<Message> + 独立的 system_prompt。 当前不做截断——故意让读者在开发过程中"撞到"上下文溢出的问题,然后在第 3 篇解决。这不是偷懒——这是让读者经历"发现问题→理解问题根源→设计方案"的完整过程,而不是给一个现成答案。
四、规则 4:封装不是在消除重复——是在画"不确定性传播边界"
直觉:“把重复的代码抽出来”
你写了一段从环境变量读 API key 的代码。然后第二段。第三段。重复三次——直觉告诉你"封装一下"。
封装后的 LlamaClient:
struct LlamaClient {
api_key: String,
base_url: String,
model: String,
http: reqwest::Client,
}
如果你把这次封装理解成"消除重复代码"(DRY),你只看到了 10% 的价值。
封装真正的价值:把不确定性锁在一道门后面
LlamaClient 包了三件东西,每件对应一种不确定性:
api_key / base_url / model——配置从哪里来?环境变量可能没设,文件可能不存在,值可能格式错误。配置不确定性。
reqwest::Client——HTTP 传输层的各种失败模式:TLS 握手失败、DNS 解析失败、连接超时、服务端 429(rate limit)、502(网关错误)。传输不确定性。
chat() / chat_stream()——LLM 的回复内容不可预测:可能是正常文本、可能是格式错误的 JSON、可能是 tool_call 但参数不合法、可能被截断(finish_reason=“length”)。内容不确定性。
三种不确定性,封装在一道门后面。门外的代码看到的是一组清洁的 Rust 类型和 Result。门外不需要知道 API key 是环境变量还是配置文件。门外不需要知道底层是 HTTP/1.1 还是 HTTP/2。门外不需要知道流式解析的 chunk 拼接逻辑。
但这道门必须是单向的
门外的代码可以调用 client.chat_stream() 并得到 Result<String>。但门外不能——不应该——绕过这扇门直接调 reqwest。因为一旦绕过,门外代码就暴露在了"内容不确定性"之下——它要自己处理流式 chunk 的 delta.content 可以是 null、tool_calls 可以增量到达、JSON 解析可能失败。
封装不是在消除重复——是在控制"不确定性传播"。 不确定性被限制在 LlamaClient 的内部。门外的代码活在一个"看起来确定"的世界里——chat_stream() 返回 Ok(String) 或 Err(anyhow::Error),仅此而已。
为什么不用全局单例
lazy_static! { static ref CLIENT: LlamaClient = ...; }
全局单例把一扇门变成了监狱的高墙。 你需要另一个 LLM 后端(比如第 6 篇 Leader 模式)——你打不开第二扇门。你需要 mock 一个 client 做测试——你无法注入依赖。
一扇门 = 可替换。一道墙 = 不可替换。
根本原则
不确定性的传播半径 = bug 排查的搜索半径。 封装不是让代码"更整洁"——是让 LLM 产生的不确定性被限制在最小的代码区域内,让 bug 排查时你只需要检查"门里面"。
五、代码:四规则的外化
想清楚了四个规则,代码只是翻译。tutorial-code/part-01-basics/ 里 ~200 行,核心结构:
// 规则 1(薄层):手工定义所有 API 类型,不用 SDK 的类型别名
struct ChatRequest { model, messages, stream }
struct Message { role, content }
struct StreamChunk { choices: Vec<StreamChoice> }
// 规则 2(协议≠语义):delta.content 用 Option,不在解析层做 unwrap
struct DeltaContent { content: Option<String> }
// 规则 3(工作记忆):system_prompt 单独保存,不混在 messages 里
struct ChatHistory { system_prompt: Option<String>, messages: Vec<Message> }
// 规则 4(不确定性边界):三种不确定性封装在一个 struct 里
struct LlamaClient { api_key, base_url, model, http: reqwest::Client }
验证:
export LLM_API_KEY="sk-your-key"
cargo run
六、总结:不是四个技术决策——是一个元问题的四个投影
| 投影 | 确定性方的要求 | 概率性 LLM 的现实 | 接口规则 |
|---|---|---|---|
| 数据格式 | JSON 必须合法 | LLM 可能输出格式错误的 JSON | 规则 1:薄到能看穿异常 |
| 流式传输 | 每一帧是完整语义单元 | LLM 的输出是渐进生成,语义边界不等于帧边界 | 规则 2:维护语义缓冲区 |
| 上下文管理 | 缓存 = 可以删的临时数据 | LLM 的上下文 = 它的全部认知 | 规则 3:删数据 = 删认知 |
| 错误隔离 | 错误应该向上传播 | LLM 的错误是"正常"输出的一种 | 规则 4:不确定性关在门内 |
这些规则不是本篇的"结论"——它们是后面 7 篇文章的设计基础。 下一篇的工具系统设计,就是规则 2(语义 vs 协议)在 Rust trait 层面的实例化:工具的参数解析要容忍 LLM 的错误输入,工具的返回值要帮 LLM 自纠正。
完整代码在 tutorial-code/part-01-basics/。
📮 每周一篇 Rust / AI Agent 深度教程,不写"怎么做",只写"为什么这么做"。
微信扫码关注「全栈之巅 - 梦兽编程」,每周获取:
- 🔬 Agent 架构设计深度分析
- 🦀 Rust 工程实践与性能调优
- 🎯 开源项目源码逆向解读
- 💬 加入学习社群,与作者直接交流

