feat(reasoning): support operator-configurable default mode via DOBOTAI_DEFAULT_MODE

This commit is contained in:
姚章浩
2026-09-15 12:25:09 +08:00
parent d6a781ba5b
commit f800127461
6 changed files with 86 additions and 14 deletions
+12
View File
@@ -79,10 +79,22 @@ func main() {
if err != nil {
log.Fatal(err)
}
defaultMode := reasoningmode.ModeQuick
if raw := strings.TrimSpace(os.Getenv("DOBOTAI_DEFAULT_MODE")); raw != "" {
switch raw {
case "reasoning":
defaultMode = reasoningmode.ModeReasoning
case "quick":
defaultMode = reasoningmode.ModeQuick
default:
log.Fatal("DOBOTAI_DEFAULT_MODE must be quick or reasoning")
}
}
reasoningService, err := reasoningmode.NewService(
store,
os.Getenv("DOBOTAI_QUICK_AGENT_ID"),
os.Getenv("DOBOTAI_REASONING_AGENT_ID"),
reasoningmode.WithDefaultMode(defaultMode),
)
if err != nil {
log.Fatal(err)
@@ -0,0 +1,2 @@
ALTER TABLE reasoning_mode_states
DROP COLUMN user_selected;
@@ -0,0 +1,6 @@
-- 000022_reasoning_default_mode: support an operator-configurable default
-- mode for the /think reasoning router. The new column records whether a
-- user has explicitly chosen a mode (/think on|off); users who never have
-- will follow DOBOTAI_DEFAULT_MODE instead of the hardcoded quick answer.
ALTER TABLE reasoning_mode_states
ADD COLUMN user_selected BOOLEAN NOT NULL DEFAULT FALSE;
+57 -8
View File
@@ -49,11 +49,28 @@ type Service struct {
repo Repository
quickAgentID string
reasoningAgentID string
defaultMode Mode
now func() time.Time
idleTimeout time.Duration
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Service, error) {
// Option customises a reasoning mode Service.
type Option func(*Service) error
// WithDefaultMode sets the mode applied to users who never explicitly chose
// one with /think. Passing ModeQuick (the default when unset) preserves the
// historical behaviour of answering with the quick agent.
func WithDefaultMode(mode Mode) Option {
return func(s *Service) error {
if mode != ModeQuick && mode != ModeReasoning {
return fmt.Errorf("invalid default reasoning mode %q", mode)
}
s.defaultMode = mode
return nil
}
}
func NewService(repo Repository, quickAgentID, reasoningAgentID string, opts ...Option) (*Service, error) {
quickAgentID = strings.TrimSpace(quickAgentID)
reasoningAgentID = strings.TrimSpace(reasoningAgentID)
if repo == nil {
@@ -65,13 +82,29 @@ func NewService(repo Repository, quickAgentID, reasoningAgentID string) (*Servic
if quickAgentID == reasoningAgentID {
return nil, errors.New("quick and reasoning agent IDs must differ")
}
return &Service{
service := &Service{
repo: repo,
quickAgentID: quickAgentID,
reasoningAgentID: reasoningAgentID,
defaultMode: ModeQuick,
now: time.Now,
idleTimeout: defaultIdleTimeout,
}, nil
}
for _, opt := range opts {
if err := opt(service); err != nil {
return nil, err
}
}
return service, nil
}
// effectivelyReasoning reports whether a turn should be served by the
// reasoning agent given the resolved state and the configured default mode.
func (s *Service) effectivelyReasoning(state State) bool {
if state.Mode == ModeReasoning {
return true
}
return !state.UserSelected && s.defaultMode == ModeReasoning
}
func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decision, error) {
@@ -95,10 +128,14 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
state.MaxRounds,
)}, nil
case CommandDisable:
_, wasReasoning, err := s.repo.Disable(ctx, scope, s.now())
previous, _, err := s.repo.ResolveState(ctx, scope, s.now())
if err != nil {
return Decision{}, err
}
wasReasoning := s.effectivelyReasoning(previous)
if _, _, err = s.repo.Disable(ctx, scope, s.now()); err != nil {
return Decision{}, err
}
if !wasReasoning {
return Decision{Handled: true, Reply: "当前未开启推理模式,默认是快速问答模式。"}, nil
}
@@ -106,7 +143,7 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
case CommandSingle:
return Decision{Query: command.Query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
case CommandHelp:
return Decision{Handled: true, Reply: helpReply()}, nil
return Decision{Handled: true, Reply: helpReply(s.defaultMode)}, nil
default:
return Decision{Handled: true, Reply: "用法:/think <问题>、/think on、/think off。"}, nil
}
@@ -119,13 +156,25 @@ func (s *Service) Prepare(ctx context.Context, scope Scope, text string) (Decisi
if state.Mode == ModeReasoning {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoning}, nil
}
// The user never chose a mode explicitly: follow the operator default.
// Default-mode reasoning runs as a single reasoning turn so it never
// enters the rounds/idle-expiry machinery; the user can pin quick mode
// any time with /think off.
if s.effectivelyReasoning(state) {
return Decision{Query: query, AgentID: s.reasoningAgentID, Execution: ExecutionReasoningSingle}, nil
}
return Decision{Query: query, AgentID: s.quickAgentID, Execution: ExecutionQuick, Expired: expired}, nil
}
func helpReply() string {
func helpReply(defaultMode Mode) string {
defaultLabel := "默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n"
if defaultMode == ModeReasoning {
defaultLabel = "默认模式:智能推理模式,支持多步思考与工具调用,效果更强、计费标准更高。\n" +
"快速问答模式:基于知识库快速准确作答,响应快、成本低,可用 /think off 切换并保持。\n"
}
return "一、模式说明\n" +
"默认模式:快速问答模式,基于知识库快速准确作答,响应快、成本低,适合信息查询和事实确认。\n" +
"智能推理模式:支持多步思考与工具调用,计费标准更高,适合复杂分析和推导。\n\n" +
defaultLabel + "\n" +
"二、模式切换指令\n" +
"/think + 你的问题:仅当前问题使用智能推理\n" +
"/think on:开启持续智能推理模式\n" +
+1
View File
@@ -22,6 +22,7 @@ var ErrInvalidState = errors.New("invalid reasoning mode state")
type State struct {
Mode Mode
UserSelected bool
MaxRounds int
RoundsUsed int
HintEnabled bool
+8 -6
View File
@@ -61,6 +61,7 @@ func (s *Store) Enable(ctx context.Context, scope reasoningmode.Scope, now time.
return reasoningmode.State{}, err
}
state.Mode = reasoningmode.ModeReasoning
state.UserSelected = true
state.RoundsUsed = 0
state.LastActivityAt = now
state.ExpiresAt = now.Add(idle)
@@ -92,6 +93,7 @@ func (s *Store) Disable(ctx context.Context, scope reasoningmode.Scope, now time
state, _ = reasoningmode.EffectiveState(state, now)
wasReasoning := state.Mode == reasoningmode.ModeReasoning
state.Mode = reasoningmode.ModeQuick
state.UserSelected = true
state.RoundsUsed = 0
state.ExpiresAt = time.Time{}
if err = updateReasoningState(ctx, tx, scope, state, now); err != nil {
@@ -256,14 +258,14 @@ func ensureReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Sc
}
func selectReasoningStateForUpdate(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3
FOR UPDATE`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
func selectReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope) (reasoningmode.State, error) {
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
return scanReasoningState(tx.QueryRow(ctx, `SELECT mode,user_selected,max_rounds,rounds_used,hint_enabled,last_activity_at,expires_at,onboarding_shown_at
FROM reasoning_mode_states
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`, scope.TenantKey, scope.Channel, scope.ExternalActorID))
}
@@ -276,7 +278,7 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
expires pgtype.Timestamptz
onboarding pgtype.Timestamptz
)
if err := row.Scan(&mode, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
if err := row.Scan(&mode, &state.UserSelected, &state.MaxRounds, &state.RoundsUsed, &state.HintEnabled, &last, &expires, &onboarding); err != nil {
return reasoningmode.State{}, fmt.Errorf("scan reasoning state: %w", err)
}
state.Mode = reasoningmode.Mode(mode)
@@ -295,11 +297,11 @@ func scanReasoningState(row pgx.Row) (reasoningmode.State, error) {
func updateReasoningState(ctx context.Context, tx pgx.Tx, scope reasoningmode.Scope, state reasoningmode.State, now time.Time) error {
_, err := tx.Exec(ctx, `UPDATE reasoning_mode_states SET
mode=$4,max_rounds=$5,rounds_used=$6,hint_enabled=$7,
last_activity_at=$8,expires_at=$9,onboarding_shown_at=$10,updated_at=$11
mode=$4,user_selected=$5,max_rounds=$6,rounds_used=$7,hint_enabled=$8,
last_activity_at=$9,expires_at=$10,onboarding_shown_at=$11,updated_at=$12
WHERE tenant_key=$1 AND channel=$2 AND external_actor_id=$3`,
scope.TenantKey, scope.Channel, scope.ExternalActorID,
string(state.Mode), state.MaxRounds, state.RoundsUsed, state.HintEnabled,
string(state.Mode), state.UserSelected, state.MaxRounds, state.RoundsUsed, state.HintEnabled,
nullableTime(state.LastActivityAt), nullableTime(state.ExpiresAt), nullableTime(state.OnboardingShownAt), now,
)
if err != nil {