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:
@@ -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"。
|
||||
// - vLLM:openai server 在 /v1,默认不校验 key(除非 --api-key 启动)→ 补 /v1 + 占位 "EMPTY"。
|
||||
// - openai-compatible(DeepSeek/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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user