Skip to content

Agent 循环 ​

一轮对话里到底发生什么。

意图路由先行 ​

用户的话先过一遍本地关键词路由(NovaService.RouteLocalAsync),命中就直接走本地引擎,不碰模型:

csharp
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」和「一问一答」的区别。写操作则一律攒起来,出确认清单,本轮到此为止。

csharp
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:

csharp
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 吞掉。表现是:流式通道一条事件都没产出过,但没有任何报错。

现在按前缀分流:

csharp
var data = (line.StartsWith("data: ", StringComparison.Ordinal) ? line[6..] : line[5..]).Trim();

data: {...} 取下标 6,data:{...} 取下标 5。改这块别回退。

消息体的大小写 ​

另一个曾经把整条链打断的坑:历史消息用匿名类型拼。

csharp
// 错的
messages.AddRange(history.Select(t => new { t.Role, t.Content }));

序列化出来是 "Role" / "Content",真服务端大小写敏感,一律 400。现在一律用显式 record:

csharp
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 拒。

csharp
messages.AddRange(history
    .Where(t => !string.IsNullOrWhiteSpace(t.Content)) // 空正文轮会被服务端 400
    .Select(t => new ChatMessage(t.Role, t.Content)));

400 的降级重试 ​

自接模型这条通道有个特殊处理:带 tools 的请求被上游 400 拒掉时(不少中转和本地小模型不认 tools),自动去掉 tools 再发一次——宁可这一轮没有工具,也要先让用户拿到回答。重试仍失败就抛第一次那个 400,因为那才是最接近真相的原因。

相关链接:工具集 —— 工具定义与信任分界 相关链接:许可与审计 —— 确认清单执行与操作日志

基于 MIT 许可证(有修改)开源