feat(llm): 本地模型接入 vLLM / Ollama + reasoning 思考过程入轨迹

vLLM 与 Ollama 都暴露 OpenAI 兼容 API(底层 go-openai 请求 {base}/chat/completions),
故统一走 openai 客户端,按 provider 归一化连接参数:
- Ollama/vLLM 的 OpenAI 端点固定在 /v1,BaseURL 漏写自动补全(否则打到 /chat/completions 404)
- 本地后端默认不校验 api_key → 缺省补占位(ollama→"ollama",vllm→"EMPTY";openai 客户端要求非空)
- 显式 key 一律尊重(vLLM --api-key 启动);在线 provider 原样不动(DeepSeek 两种都收)

reasoning_content 适配:ChatStream 增 onReasoning 回调,捕获 reasoning 模型
(本地 Qwen3 思考 / DeepSeek-R1 / QwQ、在线 deepseek-v4-pro)的思考分片。思考阶段分片
Content 为空本就不污染答案;runAgent 把思考累计后 surface 到 exec 轨迹「推理过程」事件。

控制台 ModelManager 加 ollama 选项;llm 包补 normalizeBaseURL / apiKeyOrPlaceholder 单测。
四模块全绿。live:经 admin 配 ollama qwen2.5:0.5b → dispatcher 热切(日志 base 自动 .../v1)
→ POST /v1/chat/completions 200 端到端出答案;切回 deepseek-v4-pro → exec 轨迹现「推理过程:思考26字…」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 11:10:18 +08:00
parent 05c25d7099
commit 075d41f5b3
8 changed files with 118 additions and 16 deletions
+8 -1
View File
@@ -301,12 +301,19 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
n++
}
send := func(s string) { emit(red.Push(s)) }
// reasoning 模型(本地 Qwen3 思考 / DeepSeek-R1 / QwQ 等)的「思考过程」:不进答案、
// 累计后 surface 到观测轨迹(让用户看到模型在想什么,又不污染最终回答)。
var reasoning strings.Builder
onReasoning := func(s string) { reasoning.WriteString(s) }
var err error
if o.pool.Ready() {
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send)
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
} else {
err = o.pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
}
if rc := reasoning.String(); rc != "" {
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
}
if err != nil {
tr.emit(node, "model", "error", "模型流式推理", err.Error(), time.Since(t0).Milliseconds())
// 未产出任何 token 即失败 → 标记致命错,让任务判 failed(暴露原因,便于监控告警),
@@ -24,7 +24,7 @@ type fakeLLM struct {
}
func (f *fakeLLM) Ready() bool { return f.ready }
func (f *fakeLLM) ChatStream(_ context.Context, msgs []llm.ChatMessage, onToken func(string)) error {
func (f *fakeLLM) ChatStream(_ context.Context, msgs []llm.ChatMessage, onToken func(string), _ func(string)) error {
if f.stream != nil {
onToken(f.stream(msgs))
}
+2 -2
View File
@@ -24,7 +24,7 @@ func (pm *poolModel) Generate(ctx context.Context, input []*schema.Message, _ ..
var sb strings.Builder
var err error
if pm.pool.Ready() {
err = pm.pool.ChatStream(ctx, toChatMessages(input), func(tok string) { sb.WriteString(tok) })
err = pm.pool.ChatStream(ctx, toChatMessages(input), func(tok string) { sb.WriteString(tok) }, nil)
} else {
err = pm.pool.StreamText(ctx, replyFor(input), func(tok []byte) { sb.Write(tok) })
}
@@ -44,7 +44,7 @@ func (pm *poolModel) Stream(ctx context.Context, input []*schema.Message, _ ...m
send := func(s string) { sw.Send(schema.AssistantMessage(s, nil), nil) }
var err error
if ready {
err = pm.pool.ChatStream(ctx, toChatMessages(input), send)
err = pm.pool.ChatStream(ctx, toChatMessages(input), send, nil)
} else {
err = pm.pool.StreamText(ctx, replyFor(input), func(tok []byte) { send(string(tok)) })
}
@@ -68,7 +68,7 @@ var errRejected = errors.New("approval rejected")
// LLM 是编排所需的语言模型能力(生产由 *llm.Pool 实现)。抽成接口便于测试注入假模型。
type LLM interface {
Ready() bool
ChatStream(ctx context.Context, msgs []llm.ChatMessage, onToken func(string)) error
ChatStream(ctx context.Context, msgs []llm.ChatMessage, onToken func(string), onReasoning func(string)) error
StreamText(ctx context.Context, text string, onToken func([]byte)) error
Chat(ctx context.Context, msgs []llm.ChatMessage) (string, error)
// ToolCallingModel 返回支持函数调用的模型(ReAct agent 用);不支持则返回 nil。