Agent 循环
一轮对话里到底发生什么。
意图路由先行
用户的话先过一遍本地关键词路由(NovaService.RouteLocalAsync),命中就直接走本地引擎,不碰模型:
if (lower.Contains("冲突"))
{
if (lower.Contains("处理") || lower.Contains("解决") || lower.Contains("停用"))
return await PlanConflictResolveAsync(settings.DotMinecraft, ct);
return new NovaReply(await ScanConflictsTextAsync(settings.DotMinecraft, ct), null, false);
}匹配的是「冲突」「崩溃」「闪退」「体检」「修复」「帧」「搬家」「整合包」「开箱即玩」这类中文关键词,顺序有讲究——「处理冲突」优先于泛化的「检查/修复」。
这一步存在的理由是省钱与确定性:「检查模组冲突」是一次本地目录扫描能给出确定答案的事,让模型去转述一遍既慢又不准。
有一个例外:有可用模型通道时,资源搜索类意图会主动放行给 Agent。因为 Modrinth 只有英文名,本地引擎按中文关键词搜出来永远是 0 条,Agent 可以先 translate_query 翻一道再搜。
循环本体
路由没命中就进 Agent 循环(NovaAgentLoop.RunAsync),最多 6 轮:
system prompt + 历史 + 本轮输入
↓
LLM 流式响应(带 tools)
↓
有 tool_calls?
├─ 无 → AgentFinal,收工
└─ 有 → assistant 轮记回 messages(含 tool_calls)
↓
按 ReadOnly 分流
├─ 只读 → 立即执行 → 结果作为 role:"tool" 回喂 → 继续下一轮
└─ 写操作 → 攒成 NovaPlan → AgentPlanProposed → 本轮终止只读工具立刻执行并以 role: "tool" 回喂,模型接着跑下一轮——这才是「Agent」和「一问一答」的区别。写操作则一律攒起来,出确认清单,本轮到此为止。
if (tool.ReadOnly)
{
yield return new AgentToolStarted(tool.Name, Preview(call.Arguments));
string result;
try { result = await _tools.ExecuteAsync(call.Name, call.Arguments, ct); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { result = $"执行失败:{ex.Message}"; }
messages.Add(new ChatMessage("tool", result, call.Id));
}
else
{
writes.Add((tool, call));
}工具执行失败不抛给模型,而是把「执行失败:{原因}」当成工具结果喂回去,让模型自己决定下一步(重试还是换方案)。模型只能通过工具返回值看世界。
用户点头之后,UI 执行清单,把执行结果作为新一轮对话回喂,Nova 再出一句总结。执行和总结分成两轮,因为执行耗时长且带副作用,混在一轮里会让模型在等待中反复尝试。
SSE 流式
接的是 OpenAI 兼容的流式响应,两类增量:
- 文本增量 →
choices[0].delta.content - 工具调用增量 →
choices[0].delta.tool_calls[],每项带index/id/function.name/function.arguments
工具调用的 arguments 是分片发过来的,必须按 index 拼起来才是完整 JSON:
public void Add(IReadOnlyList<SseToolCallDelta> deltas)
{
foreach (var d in deltas)
{
if (!_parts.TryGetValue(d.Index, out var part))
part = (d.Id, d.Name ?? "", new StringBuilder());
part.Id ??= d.Id;
if (d.Name is { Length: > 0 } n) part.Name = n; // 某些网关整段重发 name
if (d.ArgumentsDelta is { Length: > 0 } a) part.Args.Append(a);
_parts[d.Index] = part;
}
}Complete() 时 arguments 为空会补 {},id 缺失会补 call_{index}——网关在这两处不统一是常态。
曾经恒错的下标
SSE 行解析原来写的是 line[4] == ' ',判断「冒号后面是不是空格」。
下标 4 恒为 ':'——data: 里 d=0、a=1、t=2、a=3、:=4。于是判断永远为假,data 一直带着前导冒号进 JSON 解析,次次失败被 catch 吞掉。表现是:流式通道一条事件都没产出过,但没有任何报错。
现在按前缀分流:
var data = (line.StartsWith("data: ", StringComparison.Ordinal) ? line[6..] : line[5..]).Trim();data: {...} 取下标 6,data:{...} 取下标 5。改这块别回退。
消息体的大小写
另一个曾经把整条链打断的坑:历史消息用匿名类型拼。
// 错的
messages.AddRange(history.Select(t => new { t.Role, t.Content }));序列化出来是 "Role" / "Content",真服务端大小写敏感,一律 400。现在一律用显式 record:
public sealed record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string? Content,
[property: JsonPropertyName("tool_call_id")] string? ToolCallId = null,
[property: JsonPropertyName("tool_calls")] IReadOnlyList<ChatToolCall>? ToolCalls = null);另外序列化选项里还兜了一层 PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,即使以后有人塞进 PascalCase 的匿名对象也会被压成 content——但 JsonPropertyName 优先,所以显式 record 才是正解。
还有一处细节:空正文的轮次必须在发之前剔掉。计划卡轮和被中断的轮次正文为空,服务端会按 400 拒。
messages.AddRange(history
.Where(t => !string.IsNullOrWhiteSpace(t.Content)) // 空正文轮会被服务端 400
.Select(t => new ChatMessage(t.Role, t.Content)));400 的降级重试
自接模型这条通道有个特殊处理:带 tools 的请求被上游 400 拒掉时(不少中转和本地小模型不认 tools),自动去掉 tools 再发一次——宁可这一轮没有工具,也要先让用户拿到回答。重试仍失败就抛第一次那个 400,因为那才是最接近真相的原因。