Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .claude/agents/ai-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,16 @@ description: AI 对话编排领域。任务涉及编排器 AgentOrchestrator、T

**可靠性层(2026-08 harness 加固,治"跑一半停了")**
- LLM timeout 600s(application.yml open-router.timeout;0.36 的单值=OkHttp callTimeout 整通墙钟上限,不是空闲超时)。
- **流式模型必须 `logResponses(false)`(`ChatModelFactory.streamingBuilder`,两个流式通道共用的唯一构建口径)**。这不是调优是可靠性契约,改回 true 会让**整个传输层错误处理静默失效**:openai4j 0.23 的 `StreamingRequestExecutor$2.onFailure` 在该开关打开时先调 `ResponseLoggingInterceptor.log(response)` 再走 errorHandler,而 okhttp-sse 的 `RealEventSource.onFailure(call, e)` 在「连接失败/被断、压根没拿到响应」这条路径上传的 response **恒为 null**(另一条 `processResponse` 失败分支才是 t==null/response!=null,两者互斥,所以 response 为 null 时 t 必非 null),于是 `response.code()` 抛 NPE;`onFailure` 只 catch IOException,NPE 掀掉 OkHttp Dispatcher 线程,**紧随其后的 errorHandler 那一行永远走不到**。表现:本轮既不 onComplete 也不 onError,SSE 零字节,只能等看门狗兜底;后端日志里唯一痕迹是 `Exception in thread "OkHttp Dispatcher" ... Cannot invoke "okhttp3.Response.code()" because "response" is null`。**排障陷阱**:真正的 IOException 在这条路上被彻底销毁(`LOGGER.debug("onFailure()", t)` 那行本身也在开关内、且全仓无 logging 级别配置停在 INFO 不打印),所以「日志里只有 NPE、看不到网络错误」不代表网络没问题——修好这个开关才拿得到底层异常。回归守护 `StreamingTransportFailureTest`(连不上必须回调 onError;它走工厂那份真实 builder,用例里自己拼 builder 就永远是绿的)。非流式 `OpenAiChatModel` 走 SyncRequestExecutor 没这条路径,**所以「辅助模型秒回成功」不能用来证明流式通道的网络正常**(不同 executor、不同 OkHttpClient/连接池、且不带工具定义)。
- **流式通道是自有的 `service/ai/OpenRouterStreamingChatModel`,不再是 langchain4j 0.36 的 `OpenAiStreamingChatModel`(2026-09-02,dev-board#364)**。唯一构建口径 `ChatModelFactory.streamingModel(apiKey, baseUrl, modelId, timeout)`,平台通道与 BYOK 两个流式路径都走它;Ollama 流式仍是 langchain4j 的。换实现的直接原因是**思考型模型**:OpenRouter 对 Kimi K3(`moonshotai/kimi-k3`)这类模型从第 4 秒起就流式返回 `delta.reasoning`(真机探测:每个思考 chunk 是 `content:""` + `reasoning:"…"` + `reasoning_details:[…]`,前面夹 `: OPENROUTER PROCESSING` 注释保活),而 openai4j 0.23 的 `Delta` 只有 role/content/toolCalls/functionCall 四个字段,reasoning 在反序列化那一刻就丢了、注释行被 okhttp-sse 静默吞掉,langchain4j 只对非 null 的 content 调 `onNext`——于是几百秒的思考期间编排器收到的全是 `onNext("")`(**恰好把看门狗喂活、又一个字节都不往前端发**),用户看到的就是「思考中 281 秒、什么都没有、分不清死机还是在想」。这条流在 langchain4j 那一层没有任何钩子能拿到 reasoning,所以只能自己读 HTTP/SSE。**刻意复用不重写**:`InternalOpenAiHelper.toOpenAiMessages/toTools`(含 ImageContent 编组)、openai4j 的 `Json`(请求体,snake_case + NON_NULL + INDENT_OUTPUT——**断言请求体时先去空白**)、`OpenAiStreamingResponseBuilder`(tool_calls 按 index 拼装、usage、finish_reason),本类只管 HTTP + SSE 行协议 + 多转发两条通道。与旧实现对齐的请求参数:`stream=true`、`stream_options.include_usage=true`、`temperature=0.7`;错误语义对齐:非 2xx 抛 `OpenAiHttpException(code, body)`(`LlmErrorClassifier` 按状态码分类),IOException 原样 onError,**HTTP 200 里用 data 事件送来的 `{"error":{...}}` 也当错误**(旧实现会按空回复静默收尾)。护栏 `OpenRouterStreamingChatModelTest`(假服务端回放真机抓到的片段形状)+ `StreamingTransportFailureTest`(连不上必须 onError;它走工厂那份真实口径)。**旧的 `logResponses(false)` 地雷随之消失**(openai4j 的 `StreamingRequestExecutor$2.onFailure` 在 response==null 时先调 `ResponseLoggingInterceptor.log` 抛 NPE、errorHandler 永远走不到),但非流式 `OpenAiChatModel` 仍必须 `logRequests(false)`(请求体物化,理由在 `streamingModel` 的 javadoc)。**「辅助模型秒回成功」仍不能用来证明流式通道的网络正常**(不同 HTTP 客户端/连接池、且不带工具定义)。
- **`ReasoningStreamingHandler`**(extends `StreamingResponseHandler<AiMessage>`)多两个 default 方法:`onReasoning(delta)` 与 `onKeepAlive()`。客户端只对 `instanceof` 这个接口的 handler 转发,回放评测与各测试的脚本模型按老接口写不受影响。`AgentStreamHandler` 实现它:reasoning → SSE `reasoning_delta`(**不进 fullContentBuilder、不进编辑器流、不过标签解析**:思考文本不是正文,不落库、不回喂模型——契约 D);两者都刷新看门狗的 `lastActivityNanos`。**`streamedAnyReasoning` 与 `streamedAnyToken` 刻意分开**:看门狗选时限时任一为真都算「流已开始」(思考几分钟是正常的,改用 180s 停滞时限),而编排器的「可安全重放」判定仍只看正文——思考卡重放一遍无害,正文重放才会让用户看到重复内容。护栏 `AgentStreamHandlerReasoningTest`。
- **看门狗首字节 60s 保持不变**:真正的零字节死流仍在 60s 被掐;思考型模型靠 reasoning 增量 + OpenRouter 保活注释刷新活动时间,不会再被误杀。**注意 K3 的思考也是按输出单价计费的**($15/M),思考 281 秒的那一轮反复被掐重放会成倍烧钱——这就是首字节时限不能靠「调大」而必须靠「认得出模型还活着」来解决的原因。
- **前端**:`useAgentStream.handleEvent` 认 `reasoning_delta` → `appendReasoning()`——没有过程卡时写顶层 `bubble.thinking.content`(ghost 态的 ThinkingCard 实时滚动显示),已有工具过程后挂到最后一个过程卡的 thinking 条目(与 `<thinking>` 标签的落点同口径,否则第二轮起的思考会把首轮顶层卡的时长越算越长)。**不过 `processTextStream`**:思考文本里出现 `<final>` 字样只是模型自言自语。等待首 token 的活性计数本来就有(`sendMessage` 起算 `thinking.startTime`,ThinkingCard 按 `chat.thinkingLive` 读秒);新增的是 **SSE 链路状态 `linkStatus`**(`{state:'live'|'reconnecting', attempt}`,`scheduleReconnect` 置 reconnecting、建连成功与 `resetSSE` 回 live),ChatInterface 输入区据此渲染 `chat.linkReconnecting` 提示条——之前断线重连只写 console.warn,用户看到的是计时器一直走、分不清模型在想还是连接死了。**前端判死阈值 `HEARTBEAT_STALE_MS=45000` = 后端 `SseEmitterService.HEARTBEAT_INTERVAL_SECONDS=15` 的 3 倍**,两边任一改动都要同步(`reasoning-stream.test.mjs` 与 `SseEmitterServiceTest.heartbeatSweepReachesEveryLiveConnection` 各守一侧)。Office 插件的 `sse.js` 对未知事件名直接忽略,`reasoning_delta` 不影响任务窗格。
- **「AI 全线连不上」优先怀疑 JVM 里冻住的代理端口,不要先怀疑密钥或网络**(2026-08-16 实证,两个 e2e home + 用户真机三处复现)。macOS 上**任何 JVM 启动时都会把系统代理设置自动灌进** `http(s).proxyHost/Port` 系统属性——**不需要任何 `-D`、不需要 `JAVA_TOOL_OPTIONS`**(裸 `java Foo.java` 就已经有 `https.proxyHost=127.0.0.1`),OkHttp 走 `ProxySelector.getDefault()` 于是全部 AI 流量被送去本地代理端口。桌面后端是**长命 JVM**(开 app 起、连跑数天),启动那刻把端口**冻住**;用户的代理工具换端口或重启后(实测 1235 → 8234),后端仍在拨旧端口,**每一个 AI 请求都 `ConnectException: Connection refused`**。
- 判定三件套:`jcmd <后端PID> VM.system_properties | grep proxy` 拿 JVM 冻住的端口 → `scutil --proxy` 拿系统当前端口 → `nc -z 127.0.0.1 <旧端口>` 确认旧端口已死。两者不一致就是它。
- **已自愈**:`service/SystemProxyRefresher.java` 每 60s 对齐一次(`scutil --proxy` → `System.setProperty`),开关 `network.proxy.auto-refresh`(默认 true)。成立前提是 `DefaultProxySelector` 每次 `select()` 都重读系统属性、运行期 `setProperty` 立即生效(由 `SystemProxyRefresherTest` 的端到端用例守住);**运行期打开 `java.net.useSystemProxies` 无效**(类初始化时固化,返 DIRECT),所以只能自己读 OS 再写属性。启用条件刻意收窄成「macOS + 启动时继承到回环代理」:非回环的企业代理端口稳定,动它只有风险。**启动时系统没开代理的情况不接管**(没有被冻住的旧端口,不存在要治的病),那种情况仍靠重启后端。老版本(≤ v0.16.0)没有这层自愈,临时解仍是重启 app。
- **表现极具迷惑性,两个假信号**:① 修复前流式路撞上文那个 NPE 被吞、静默 180s,日志里只有 NPE 看不到 ConnectException;② **同步路(辅助模型起标题/记忆/分类器)会「秒回」**——但那是 RetryUtils 重试 3 次约 1.4s 全败后写入的**兜底字面量「新对话」**,不是成功。**排障时先看标题是不是字面量「新对话」**,别拿它当"通道正常"的证据。
- **找日志别找错地方**:`-Duser.home=` 会整体改写 `~/.aiworkdeck` 的位置,e2e 后端的日志在 `<user.home>/run/backend.log`。在真实 `~/.aiworkdeck/logs/backend.log` 里翻 e2e 的证据只会得出「什么都没有」的错误结论。
- `AgentStreamHandler`:终态幂等(AtomicBoolean terminated)+ **流看门狗** armInactivityWatchdog(**首字节 60s / 停滞 180s**,5s 轮询)——两条时限刻意分开:停滞时限要照顾「生成长工具参数时中途静默几十秒」所以必须给足,而「从头到尾零字节」没有这种正当理由,合成一个值就是让用户干等三分钟。首字节这条只在 `streamedAnyToken == false` 时生效,而这恰好就是编排器判定「可安全重放」的条件,所以误杀代价上限是白跑一轮、不会让用户看到重复或半截内容。守护 `AgentStreamWatchdogTest`。
- `AgentStreamHandler`:终态幂等(AtomicBoolean terminated)+ **流看门狗** armInactivityWatchdog(**首字节 60s / 停滞 180s**,5s 轮询)——两条时限刻意分开:停滞时限要照顾「生成长工具参数时中途静默几十秒」所以必须给足,而「从头到尾零字节」没有这种正当理由,合成一个值就是让用户干等三分钟。首字节这条只在 `streamedAnyToken == false && streamedAnyReasoning == false` 时生效(前者恰好是编排器判定「可安全重放」的条件,所以误杀代价上限是白跑一轮、不会让用户看到重复或半截内容;思考增量与 OpenRouter 保活注释(`onReasoning` / `onKeepAlive`)都刷新活动时间,思考型模型静默几分钟不会被首字节时限掐掉。守护 `AgentStreamWatchdogTest` + `AgentStreamHandlerReasoningTest`。
- `AgentOrchestrator.setOnError`:失败按 `LlmErrorClassifier.Kind` 分类(**七类**:RATE_LIMITED / TRANSIENT / MODEL_UNAVAILABLE / REGION_BLOCKED / **QUOTA_EXHAUSTED** / **CONTEXT_OVERFLOW** / FATAL,OpenAiHttpException 的结构化状态码优先于文本匹配),且**零 token 已流出**才允许重放。限流退避 30/60s ×2(限流窗口按分钟计,用 8/16/32 会在同一窗口连撞三次白烧预算),瞬时 8/16/32s ×3(RunGuard.llmRetries,成功轮与切模型后清零);用户文案两套,限流说「限流等待中」不说「服务不可用」。
- **QUOTA_EXHAUSTED = 配额/余额耗尽**(2026-08 对标 dsh):402、或 4xx + 配额语义(insufficient credits/quota/balance、quota exceeded、余额不足…)。**判定先于 429**——余额耗尽很多服务商也回 429,但它是终局:不退避(重试白烧)、不换模型(同一账户换哪个都没钱)。SSE error 载荷带 `AI_QUOTA_EXHAUSTED` 标记(`LlmErrorClassifier.QUOTA_EXHAUSTED_MARKER`),前端 useAgentStream includes 命中换中文引导(自备 Key 去服务商充值 / 平台通道去官网查额度分配)。
- **CONTEXT_OVERFLOW = 上下文超窗**(400 + 上下文语义,先于通用 400→FATAL 判定):不退避(原样重发必撞同一个 400)、不走故障转移链,走**专用恢复通道**——`RunLoopCompactor.forceCompact`(跳过阈值判断)强制压缩后同 depth 重放一次。**重试凭证 = compact 返回了新实例(确实缩小了)**,压不动直接终态(载荷带 `AI_CONTEXT_OVERFLOW` 标记换中文引导)。预算 `RunGuard.overflowCompactions` 1 次/轮,成功轮清零(长任务「涨→压→涨→压」合法)。存在意义:主动 compaction 靠 chars/token=2 估算,中文语料系统性低估,服务商的 400 是最后的事实来源。
Expand Down Expand Up @@ -158,7 +161,7 @@ ChatInterface.handleSubmit(~:927)→ useAgentStream.sendMessage(确保 SSE

## SSE 事件名清单

connected / bubble_start / text_delta / artifact / token_usage / bubble_end(status: finished|paused|awaiting_approval|awaiting_input)/ error / cancelled / file_change / client_action / title_update / doc_stream_data(旧名 wps_stream_data 双轨待摘)/ state_recovery(断线重连快照)/ run_state / plan_update / **skill_update** / background_task_start|complete / task_progress / heartbeat / subtask_progress。前端分派均在 useAgentStream.handleEvent。超限 paused 契约见 PR#172。
connected / bubble_start / text_delta / **reasoning_delta**(思考型模型的 reasoning 增量,`{"content":"…"}`,只进思考卡、不进正文与历史;state_recovery 快照不含它,重连后思考文本不回放)/ artifact / token_usage / bubble_end(status: finished|paused|awaiting_approval|awaiting_input)/ error / cancelled / file_change / client_action / title_update / doc_stream_data(旧名 wps_stream_data 双轨待摘)/ state_recovery(断线重连快照)/ run_state / plan_update / **skill_update** / background_task_start|complete / task_progress / heartbeat / subtask_progress。前端分派均在 useAgentStream.handleEvent。超限 paused 契约见 PR#172。

**`skill_update`(本轮生效的 skill 清单)**:载荷 `{"skills":[{"id","name","source"}]}`,source ∈ `auto`(触发词自动命中)/ `manual`(用户在面板里主动选的,含旧字段 pinnedSkillId);`name` 已由 `SkillRouter.displayName` 按应用语言解析(en 优先 name_en)。生产者只有 `AgentOrchestrator`,紧跟 `activateForTurn` 之后发一次。
- **每轮必发、空也发**:前端拿它做整表覆写(`useAgentStream.activeSkills`),漏发一次上一轮的 chip 就一直挂着,用户以为某个技能还生效着。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* 于是全部出站流量被送去本地代理端口。而桌面后端是**长命 JVM**(开 app 起、连跑数天),
* 把端口冻在启动那一刻;用户的代理软件换端口或重启后(实测 1235 → 8234),
* 后端一直在拨那个已经没人监听的旧端口,**每一个 AI 请求都 ConnectException**。
* 修复前这个失败还会被 openai4j 吞掉(见 {@code ChatModelFactory.streamingBuilder}),
* 修复前这个失败还会被 openai4j 吞掉(旧流式通道的 logResponses NPE 地雷,
* 见 {@code ChatModelFactory.streamingModel} 的 javadoc;流式通道已换成自有实现),
* 用户看到的是"点了发送三分钟没反应"。
*
* <p><b>为什么必须改属性而不是开 {@code java.net.useSystemProxies}</b>:那个开关在
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.checkba.service.ai;

import dev.langchain4j.model.output.Response;
import dev.langchain4j.model.StreamingResponseHandler;
import dev.langchain4j.data.message.AiMessage;

import java.util.UUID;
Expand All @@ -10,7 +9,7 @@
* 负责将 LLM 的流式回调转换为前端 SSE 协议事件。
* 并收集最终完整的回复用于存储和计费。
*/
public class AgentStreamHandler implements StreamingResponseHandler<AiMessage> {
public class AgentStreamHandler implements ReasoningStreamingHandler {

private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AgentStreamHandler.class);

Expand Down Expand Up @@ -38,7 +37,12 @@ public class AgentStreamHandler implements StreamingResponseHandler<AiMessage> {
new java.util.concurrent.atomic.AtomicBoolean(false);
// 是否已有 token 流出(重试决策依据:零 token 的失败轮可安全重放,不会给用户看重复内容)
private volatile boolean streamedAnyToken = false;
// 最近一次流活动时间(onNext 刷新),看门狗据此判定"流停滞"
// 是否已有思考增量流出(思考型模型)。刻意与 streamedAnyToken 分开:
// - 看门狗选时限时两者任一为真都算「流已开始」,改用停滞时限(思考几分钟是正常的);
// - 编排器判「可安全重放」仍只看 streamedAnyToken——思考文本重放一遍用户只是再看一次
// 思考卡,正文重放才会出现重复内容。
private volatile boolean streamedAnyReasoning = false;
// 最近一次流活动时间(onNext / onReasoning / onKeepAlive 刷新),看门狗据此判定"流停滞"
private volatile long lastActivityNanos = System.nanoTime();
private volatile java.util.concurrent.ScheduledFuture<?> watchdogFuture;

Expand Down Expand Up @@ -75,7 +79,7 @@ public void armInactivityWatchdog(int firstTokenSeconds, int inactivitySeconds)
watchdogFuture = WATCHDOG.scheduleWithFixedDelay(() -> {
if (terminated.get()) return;
long idleSec = (System.nanoTime() - lastActivityNanos) / 1_000_000_000L;
boolean started = streamedAnyToken;
boolean started = streamedAnyToken || streamedAnyReasoning;
int limitSec = started ? inactivitySeconds : firstTokenSeconds;
if (idleSec >= limitSec) {
log.warn("Stream {} for {}s (limit {}s) for {}, terminating round via watchdog",
Expand Down Expand Up @@ -138,6 +142,32 @@ public void onNext(String token) {
}
}

/**
* 思考增量(dev-board#364):原样转发成 SSE {@code reasoning_delta},前端实时渲染进思考卡。
*
* <p>刻意不进 {@link #fullContentBuilder}、不进编辑器流、不过标签解析:思考文本不是模型正文,
* 不落库、不回喂模型(契约 D:模型只看 content),也不该被写进文档。
*/
@Override
public void onReasoning(String reasoningDelta) {
if (terminated.get() || reasoningDelta == null || reasoningDelta.isEmpty()) return;
lastActivityNanos = System.nanoTime();
streamedAnyReasoning = true;
sseEmitterService.send(conversationId, "reasoning_delta",
"{\"content\":\"" + escapeJson(reasoningDelta) + "\"}");
}

/** 传输层保活注释:只刷新看门狗,不产生任何事件。 */
@Override
public void onKeepAlive() {
if (terminated.get()) return;
lastActivityNanos = System.nanoTime();
}

public boolean hasStreamedReasoning() {
return streamedAnyReasoning;
}

// ==================== Editor Stream Filtering Logic(过滤后实时写入编辑器文档;SSE 事件名双轨 doc_stream_data/wps_stream_data,见 AgentOrchestrator) ====================

// Buffer for editor stream parser to handle split tags
Expand Down
Loading
Loading