diff --git a/CLAUDE.md b/CLAUDE.md index 7d78d96..6d87aa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ - **新增并行点的固定三处**:`common/pool.go` 加池变量(grpool 封装)→ `biz/consts` 加默认大小 → `config.yml` 的 `pool` 段加 `key: 并发度`(缺失或非法时回退默认值) - 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池(池清单与默认值见 README 配置说明) - **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine +- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外) - **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程 ## 文档职责(三文档体系) diff --git a/data/business.db b/data/business.db index 53b6bd5..828a3b6 100644 Binary files a/data/business.db and b/data/business.db differ diff --git a/data/chat.db b/data/chat.db index a277aca..5803fa7 100644 Binary files a/data/chat.db and b/data/chat.db differ diff --git a/data/system.db b/data/system.db index 83a9ed4..56b7313 100644 Binary files a/data/system.db and b/data/system.db differ diff --git a/kb/consts/consts.go b/kb/consts/consts.go index 9978f64..5eb092e 100644 --- a/kb/consts/consts.go +++ b/kb/consts/consts.go @@ -26,7 +26,10 @@ const ( MaxReactRounds = 10 // ReAct 轮次上限 ToolResultMaxChars = 1500 // search 工具返回的单条 chunk 截断字数(控制上下文体积) - ParsePollIntervalSeconds = 5 // 解析/标注任务轮询间隔 + ParsePollIntervalSeconds = 15 // 解析/标注任务轮询间隔 + + DefaultLlmHttpTimeout = 600 // LLM HTTP 客户端超时(秒):本地模型生成慢,且多请求排队时响应可超分钟级 + MaxExtractTokens = 4096 // 知识抽取单次生成的 token 预算(防失控生成烧掉上下文) EmbedBatchSize = 16 // 单次向量化请求的文本批量 diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go index b8af69d..549fafe 100644 --- a/kb/service/chat_service.go +++ b/kb/service/chat_service.go @@ -33,8 +33,6 @@ import ( "github.com/gogf/gf/v2/frame/g" ) -var httpClient = &http.Client{Timeout: 2 * time.Minute} - // ---------- OpenAI 兼容 HTTP 组件 ---------- type openAIMessage struct { @@ -76,10 +74,17 @@ type openAIStreamChunk struct { type OpenAIChatModel struct { cfg *entity.ModelConfig tools []*schema.ToolInfo + extra map[string]any // 附加请求字段(如 gemma-4 关闭思维链) } func NewOpenAIChatModel(cfg *entity.ModelConfig) *OpenAIChatModel { - return &OpenAIChatModel{cfg: cfg} + return &OpenAIChatModel{cfg: cfg, extra: map[string]any{}} +} + +// DisableThinking 关闭模型思维链:本地 gemma-4-E4B 偶发长思考会烧光上下文,导致返回空内容 +func (m *OpenAIChatModel) DisableThinking() *OpenAIChatModel { + m.extra["thinking"] = false + return m } func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...emodel.Option) (*schema.Message, error) { @@ -88,6 +93,12 @@ func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, "messages": buildOpenAIMessages(input), "stream": false, } + for k, v := range m.extra { + payload[k] = v + } + if commonOpts := emodel.GetCommonOptions(nil, opts...); commonOpts.MaxTokens != nil { + payload["max_tokens"] = *commonOpts.MaxTokens + } m.withTools(payload) body, err := postOpenAI(ctx, m.cfg, m.endpoint("/chat/completions"), payload) if err != nil { @@ -1174,7 +1185,9 @@ func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, p if cfg.ApiKey != "" { req.Header.Set("Authorization", "Bearer "+cfg.ApiKey) } - resp, err := httpClient.Do(req) + // 本地模型生成慢且多请求排队时响应可超分钟级,超时取 config.yml chat.timeout(秒) + timeout := g.Cfg().MustGet(ctx, "chat.timeout", consts.DefaultLlmHttpTimeout).Int() + resp, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(req) if err != nil { g.Log().Errorf(ctx, "model call failed: model=%s url=%s err=%v", cfg.ModelName, url, err) return nil, err diff --git a/kb/service/kg_entity_service.go b/kb/service/kg_entity_service.go index 7713c28..2d4fadb 100644 --- a/kb/service/kg_entity_service.go +++ b/kb/service/kg_entity_service.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "strings" "sync" @@ -11,9 +12,9 @@ import ( "rag-local/kb/dao" "rag-local/kb/model/entity" + emodel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" ) var KgEntityService = &kgEntityService{} @@ -48,6 +49,8 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume if err != nil { return 0, err } + // 池内并发共享 model 实例,只读字段须在此(单 goroutine)预置好 + model.DisableThinking() type extractOut struct { chunkId int64 @@ -69,7 +72,7 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume ch <- extractOut{chunkId: c.Id, err: e} }); err != nil { wg.Done() - g.Log().Warningf(ctx, "kg extract chunk %d submit failed: %v", c.Id, err) + fmt.Printf("kg extract chunk %d submit failed: %v\n", c.Id, err) } } go func() { wg.Wait(); close(ch) }() @@ -78,12 +81,12 @@ func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, docume for out := range ch { if out.err != nil { failed++ - g.Log().Warningf(ctx, "kg extract chunk %d failed: %v", out.chunkId, out.err) + fmt.Printf("kg extract chunk %d failed: %v\n", out.chunkId, out.err) continue } if err := s.saveExtract(ctx, datasetId, out.chunkId, out.entities, out.relations); err != nil { failed++ - g.Log().Warningf(ctx, "kg extract chunk %d failed: %v", out.chunkId, err) + fmt.Printf("kg extract chunk %d failed: %v\n", out.chunkId, err) } } return failed, nil @@ -106,13 +109,22 @@ func (s *kgEntityService) callExtract(ctx context.Context, model *OpenAIChatMode {Role: schema.System, Content: "你是知识抽取助手。从文档片段中抽取实体(人名、组织、地名、产品等专有名词)及实体间的关系(动词或介词短语)。只输出 JSON,不要 markdown 代码块或任何解释,格式:{\"entities\":[{\"name\":\"实体名\",\"type\":\"类型\"}],\"relations\":[{\"head\":\"主体\",\"relation\":\"关系\",\"tail\":\"客体\"}]}"}, {Role: schema.User, Content: "文档片段:\n" + chunk.Content}, } - resp, err := model.Generate(ctx, msgs) + resp, err := model.Generate(ctx, msgs, emodel.WithMaxTokens(consts.MaxExtractTokens)) if err != nil { return nil, nil, err } data, err := parseKgJSON(resp.Content) if err != nil { - return nil, nil, err + // 模型偶发返回空内容/截断 JSON,重试一次通常正常 + fmt.Printf("kg extract chunk %d parse failed (%v), retrying\n", chunk.Id, err) + resp, err = model.Generate(ctx, msgs, emodel.WithMaxTokens(consts.MaxExtractTokens)) + if err != nil { + return nil, nil, err + } + data, err = parseKgJSON(resp.Content) + if err != nil { + return nil, nil, err + } } entities := make([]kgEntityItem, 0, len(data.Entities)) for _, e := range data.Entities { @@ -154,7 +166,7 @@ func (s *kgEntityService) saveExtract(ctx context.Context, datasetId, chunkId in return nil } -// parseKgJSON 解析模型输出的 JSON,容忍 ```json 代码块包裹 +// parseKgJSON 解析模型输出的 JSON,容忍 ```json 代码块包裹及首个 JSON 对象后的多余内容(模型偶尔续写 ",{...}") func parseKgJSON(content string) (*kgExtractResult, error) { content = strings.TrimSpace(content) content = strings.TrimPrefix(content, "```json") @@ -163,11 +175,52 @@ func parseKgJSON(content string) (*kgExtractResult, error) { content = strings.TrimSpace(content) var out kgExtractResult if err := json.Unmarshal([]byte(content), &out); err != nil { - return nil, gerror.Wrap(err, "解析抽取 JSON 失败") + if fixed, ok := extractFirstJSON(content); ok { + if err2 := json.Unmarshal([]byte(fixed), &out); err2 == nil { + return &out, nil + } + } + return nil, gerror.Wrapf(err, "解析抽取 JSON 失败,模型输出:%q", truncateRunes(content, 200)) } return &out, nil } +// extractFirstJSON 提取字符串中第一个完整 JSON 对象(平衡花括号,跳过字符串内的花括号) +func extractFirstJSON(s string) (string, bool) { + start := strings.IndexByte(s, '{') + if start < 0 { + return "", false + } + depth := 0 + inStr := false + escaped := false + for i := start; i < len(s); i++ { + ch := s[i] + if inStr { + if escaped { + escaped = false + } else if ch == '\\' { + escaped = true + } else if ch == '"' { + inStr = false + } + continue + } + switch ch { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1], true + } + } + } + return "", false +} + func (s *kgEntityService) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgEntity, int, error) { return dao.KgEntity.List(ctx, datasetId, page, pageSize) }