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
+58 -9
View File
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"strings"
"sync"
"time"
@@ -42,12 +43,7 @@ func NewPool() *Pool { return &Pool{} }
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
var cm model.BaseChatModel
if cfg != nil && cfg.Ready() {
built, err := openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Timeout: requestTimeout,
})
built, err := buildChatModel(cfg)
if err != nil {
fmt.Printf("[llm] 构建 ChatModel 失败(降级桩运行): %v\n", err)
} else {
@@ -60,10 +56,58 @@ func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
p.mu.Unlock()
if cfg != nil {
// 不打印 api_key。
fmt.Printf("[llm] model config set: provider=%s base=%s model=%s\n", cfg.Provider, cfg.BaseURL, cfg.Model)
fmt.Printf("[llm] model config set: provider=%s base=%s model=%s\n", cfg.Provider, normalizeBaseURL(cfg), cfg.Model)
}
}
// buildChatModel 据 provider 归一化连接参数后构建 OpenAI 兼容 ChatModel。
// vLLM 与 Ollama 都暴露 OpenAI 兼容 API(底层 go-openai 请求 {base}/chat/completions),
// 故统一走 openai 客户端,仅差在 BaseURL 是否带 /v1 与是否需要占位 key:
// - Ollama:端点固定为 {host}/v1,且不校验 api_key → 缺省补 /v1 + 占位 key "ollama"。
// - vLLMopenai server 在 /v1,默认不校验 key(除非 --api-key 启动)→ 补 /v1 + 占位 "EMPTY"。
// - openai-compatibleDeepSeek/OpenAI 等在线):BaseURL 原样(DeepSeek 两种都收,OpenAI 默认带 /v1)。
func buildChatModel(cfg *contract.ModelConfig) (model.BaseChatModel, error) {
return openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
APIKey: apiKeyOrPlaceholder(cfg),
BaseURL: normalizeBaseURL(cfg),
Model: cfg.Model,
Timeout: requestTimeout,
})
}
// 本地后端 provider 标识(与控制台下拉、contract.ModelConfig.Provider 对齐)。
const (
providerOllama = "ollama"
providerVLLM = "vllm"
)
// normalizeBaseURL 为本地后端补全 /v1 端点(Ollama/vLLM 的 OpenAI 兼容 API 均在 /v1
// 漏写会打到 /chat/completions 而 404);在线 provider 原样返回。
func normalizeBaseURL(cfg *contract.ModelConfig) string {
base := strings.TrimRight(cfg.BaseURL, "/")
switch strings.ToLower(cfg.Provider) {
case providerOllama, providerVLLM:
if base != "" && !strings.HasSuffix(base, "/v1") {
base += "/v1"
}
}
return base
}
// apiKeyOrPlaceholder 为不校验 key 的本地后端补占位符(openai 客户端要求 api_key 非空)。
func apiKeyOrPlaceholder(cfg *contract.ModelConfig) string {
if cfg.APIKey != "" {
return cfg.APIKey
}
switch strings.ToLower(cfg.Provider) {
case providerOllama:
return "ollama"
case providerVLLM:
return "EMPTY"
}
return cfg.APIKey
}
func (p *Pool) config() *contract.ModelConfig {
p.mu.RLock()
defer p.mu.RUnlock()
@@ -99,8 +143,10 @@ func (p *Pool) ModelName() string {
}
// ChatStream 流式推理,逐 token 回调 onToken(经 Eino ChatModel.Stream)。
// 仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。
func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string)) error {
// onReasoning(可空)接收 reasoning 模型(DeepSeek-R1 / Qwen3 思考 / QwQ 等)的「思考过程」分片:
// 思考阶段的分片只有 ReasoningContent、Content 为空,本就不会污染答案;onReasoning 让调用方
// 可把思考流单独 surface 到观测轨迹。仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。
func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string), onReasoning func(string)) error {
cm := p.model()
if cm == nil {
return fmt.Errorf("no model configured")
@@ -125,6 +171,9 @@ func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(
if rerr != nil {
return fmt.Errorf("llm stream recv: %w", rerr)
}
if chunk.ReasoningContent != "" && onReasoning != nil {
onReasoning(chunk.ReasoningContent)
}
if chunk.Content != "" {
onToken(chunk.Content)
}