Chenyme's Blog
主页文章项目
Chenyme's BlogChenyme's Blog

我是 Chenyme,专注于把AI、交互和技术融合在一起,用更克制的设计和稳定的工程交付长期可用的产品。

  • Telegram

网站

  • 首页
  • 博客
  • 项目
  • 关于

资源

  • 关于我
  • Github 仓库
  • 服务条款
  • 隐私政策

友链

  • Cheny Blog
  • YouTube
  • LINUX DO
  • Google 学术

© 2026 Chenyme's Blog. All rights reserved.

  1. Blog
  2. 从能聊到可靠:多轮对话系统的工程化设计

从能聊到可靠:多轮对话系统的工程化设计

CChenyme发布于 2026年05月14日
多轮对话
多轮对话系统工程化设计

多轮对话系统真正的门槛,不是接入大模型,而是把上下文、分支、RAG、工具调用、流式恢复和失败收敛变成一套可控的工程流程。本文从生产系统视角出发,拆解一个可靠对话链路该如何设计:消息如何落库,分支如何隔离,上下文如何规划,工具如何闭环,异常如何恢复,以及为什么 trace 必须成为业务证据。

多轮对话系统的难点,不是把一句话转发给模型,也不是把回答流式吐回前端。真正困难的是:一次请求会同时牵涉历史消息、分支、文件、RAG、记忆、工具、计费、取消、重试和上游路由。任何一环没有边界,最后都会表现成用户能感知的问题:
  • 刷新页面后,生成到一半的回答丢失。
  • 编辑消息后,模型仍然读到旧分支上下文。
  • RAG 检索失败,模型却像读过资料一样回答。
  • 工具调用重复执行,成本和延迟失控。
  • 上游超时后,数据库里留下无法解释的 pending 消息。
  • 线上排障时,只能看到一条 timeout,看不到本轮 prompt 结构。
可靠的多轮对话系统,本质不是「LLM API 封装」,而是一条可恢复、可追踪、可限流的业务工作流。

设计目标

一个生产级对话系统至少要满足六个目标:
目标
含义
状态可收敛
成功、失败、取消、超时都能落到明确终态
上下文可解释
模型看到的内容都有来源和边界
分支不串线
重试、编辑、重新生成不会污染默认续聊
依赖可降级
RAG、工具、上游 stateful 失败后有回退路径
资源有上限
Token、工具次数、LLM 调用次数、事件缓存都有限制
过程可追踪
能还原本轮用了什么上下文、工具和回退策略
下面按关键模块展开。

设计思路

状态先行

不要等模型成功后再写数据库。请求一开始就应该创建 Run,并创建两条占位消息:
  • user 消息:表示系统已接收输入。
  • assistant 消息:表示本轮即将生成回答。
这样即使中途失败,也有业务实体可以收敛。
Codego
type RunStatus string

const (
	Pending  RunStatus = "pending"
	Success  RunStatus = "success"
	Error    RunStatus = "error"
	Canceled RunStatus = "canceled"
)

type Run struct {
	ID             string
	ConversationID uint
	UserID         uint
	Status         RunStatus
	ErrorCode      string
	InputTokens    int64
	OutputTokens   int64
	StartedAt      time.Time
	EndedAt        *time.Time
}

type Message struct {
	ID             uint
	ConversationID uint
	ParentID       *uint
	RunID          string
	Role           string
	Content        string
	Status         RunStatus
	ErrorCode      string
}
主流程建议用统一出口收敛状态。这里用伪代码表达关键顺序:
Codego
send(input):
  run = create_run(status=pending)
  user_msg = create_message(role=user, status=pending)
  assistant_msg = create_message(role=assistant, status=pending)

  try:
    result = generate(run, user_msg, assistant_msg, input)
    mark_success(run, user_msg, assistant_msg, result)
    return result

  catch GenerationCanceled:
    save_partial_text(assistant_msg)
    mark_canceled(run, assistant_msg)

  catch err:
    mark_error(run, user_msg, assistant_msg, classify(err))
状态收敛的核心规则:
  • 成功:写入助手正文、token、延迟,标记 success。
  • 失败:写入错误码和错误摘要,标记 error。
  • 取消:保留已生成的部分文本,标记 canceled。
  • 超时:不要留下裸 pending,必须归类成明确错误。

路径上下文

多轮对话不是简单列表。只要支持编辑、重试、重新生成,就会自然形成消息树。
错误做法是取最近 N 条:
Codego
// 容易串分支:只按时间取消息。
history := repo.ListRecentMessages(conversationID, 20)
正确做法是从当前叶子消息回溯父链。比如:
Codego
func BuildPath(messages []Message, leafID uint) []Message {
	byID := map[uint]Message{}
	for _, m := range messages {
		byID[m.ID] = m
	}

	var path []Message
	seen := map[uint]bool{}
	for id := leafID; id != 0; {
		m, ok := byID[id]
		if !ok || seen[id] {
			break
		}
		seen[id] = true
		path = append(path, m)
		if m.ParentID == nil {
			break
		}
		id = *m.ParentID
	}

	for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
		path[i], path[j] = path[j], path[i]
	}
	return path
}
而默认续聊时,也不要直接选择最新数据库行。应该选择最近一条成功消息,跳过 pending、error、canceled。
Codego
func DefaultParent(recent []Message) *Message {
	for i := len(recent) - 1; i >= 0; i-- {
		m := recent[i]
		if m.Role == "assistant" && m.Status == Success {
			return &m
		}
	}
	return nil
}
一句话原则:前端可以按时间展示,后端必须按路径组装上下文。

Prompt 规划

复杂系统里,prompt 来源很多:系统策略、历史对话、摘要、记忆、文件、RAG、工具结果、工具规则。不能让这些模块到处 append 字符串。
需要一个统一的 PromptPlan。结构可以很简单:
Codego
type BlockKind string

const (
	SystemPolicy BlockKind = "system_policy"
	Transcript   BlockKind = "transcript"
	Stable       BlockKind = "stable_context"
	Dynamic      BlockKind = "dynamic_context"
	ToolGuide    BlockKind = "tool_guidance"
)

type PromptBlock struct {
	Kind      BlockKind
	Content   string
	Tokens    int64
	Cacheable bool
	Sources   []SourceRef
}

type PromptPlan struct {
	Messages []LLMMessage
	Trace    PromptTrace
}
推荐顺序:
  1. 系统策略
  2. 稳定上下文,例如文件全文、固定规则
  3. 历史对话
  4. 动态上下文,例如 RAG、记忆、语义召回
  5. 工具规则
PromptPlan 不只是拼装器,更是审计边界。它应该记录每个 block 的 token、来源、是否可缓存。
Codego
type PromptTrace struct {
	TotalTokens int64
	Blocks      []BlockTrace
}

type BlockTrace struct {
	Kind        BlockKind
	Tokens      int64
	Cacheable   bool
	SourceCount int
}
没有 PromptTrace,线上排障时只能看完整 prompt;有了 PromptTrace,可以直接看到本轮是否包含文件、RAG、记忆、摘要、工具规则,以及各自占了多少预算。

证据隔离

RAG 片段、文件内容、工具结果是资料,不是系统指令。
推荐 role 边界:
内容
推荐位置
平台规则、模型行为约束
system
用户输入、资料型上下文
user
工具执行结果
tool
模型回答
assistant
资料型上下文可以放进带边界的用户内容里:
Codexml
<ctx>
  <summary>用户正在讨论企业知识库权限模型。</summary>
  <rag source="pricing.pdf#chunk-12">
    企业版支持 SSO、审计日志和自定义数据保留周期。
  </rag>
  <memory key="language">用户偏好中文回答。</memory>
</ctx>

<user_input>
  企业版和团队版有什么区别?
</user_input>
这不能单独解决 prompt injection,但能建立更清晰的权限层级:资料可以被引用,但不能天然覆盖系统策略。

记忆分层

长对话不能只靠最近 N 条,也不能只靠摘要或向量召回。更稳的方式是分层:
层级
作用
最近消息
保留局部指代和对话节奏
会话摘要
保留远期主线
语义召回
找回被截断但相关的细节
上下文证据
保存曾经用过的 RAG、工具结果、摘要
上下文是有限资源,需要预算器。下面是一个完整的小型选择器:
Codego
type Slot struct {
	Kind     string
	Content  string
	Tokens   int64
	Priority int
	Required bool
}

func SelectSlots(slots []Slot, budget int64) []Slot {
	sort.SliceStable(slots, func(i, j int) bool {
		if slots[i].Required != slots[j].Required {
			return slots[i].Required
		}
		return slots[i].Priority > slots[j].Priority
	})

	var used int64
	var out []Slot
	for _, s := range slots {
		if s.Required || used+s.Tokens <= budget {
			out = append(out, s)
			used += s.Tokens
		}
	}
	return out
}
常见优先级:
内容
优先级
当前输入
100
用户偏好
90
会话摘要
80
RAG 证据
70
语义召回
60
长期记忆
50
更早历史
40
数字可以调整,但原则不能变:上下文必须被调度,而不是无限堆叠。

RAG 降级

RAG 最危险的失败方式不是报错,而是静默消失。系统应该显式区分检索状态。
Codego
type RetrieveStatus string

const (
	Hit      RetrieveStatus = "hit"
	Empty    RetrieveStatus = "empty"
	LowScore RetrieveStatus = "low_score"
	Timeout  RetrieveStatus = "timeout"
	Failed   RetrieveStatus = "failed"
)

type RetrieveResult struct {
	Status         RetrieveStatus
	Reason         string
	Chunks         []Chunk
	CandidateCount int
	MaxScore       float64
}
调用方不要只判断 len(chunks)。
Codego
retrieve_context(query, files):
  result = rag.retrieve(query, files)

  if result.error:
    trace("rag failed; fallback to full text")
    return full_text_fallback(files)

  if result.chunks is empty:
    trace("rag miss", status=result.status, reason=result.reason)
    return evidence_miss(result.reason)

  trace("rag hit", count=len(result.chunks), max_score=result.max_score)
  return rag_chunks(result.chunks)
好的 RAG 流程要告诉系统三件事:
  • 找到了什么。
  • 没找到是因为空、低分、超时还是错误。
  • 是否回退到了全文或其他证据。

工具闭环

工具调用必须是受限循环。模型可以请求工具,但循环控制权必须在系统。
Codego
run_tool_loop(messages, tools):
  ledger = {}
  llm_calls = 0
  tool_calls = 0

  while llm_calls < max_llm_calls:
    output = llm.generate(messages, tools)
    llm_calls += 1

    if output.tool_calls is empty:
      return output

    for call in output.tool_calls:
      if tool_calls >= max_tool_calls:
        disable_tools("tool limit reached")
        return llm.generate(messages, tools=[])

      key = call.name + canonical_json(call.arguments)
      if key not in ledger:
        validate(call.arguments, schema=call.schema)
        result = execute_tool(call)
        ledger[key] = truncate_for_model(result)
        tool_calls += 1

      messages.append(tool_result(call.id, ledger[key]))

  disable_tools("llm call limit reached")
  return llm.generate(messages, tools=[])
必须做的限制:
  • 参数 schema 校验。
  • 相同工具和参数去重。
  • 工具结果截断后再回灌模型。
  • 完整工具结果进入 trace 或数据库。
  • 达到上限后强制综合回答。
没有这些限制,工具调用很容易变成成本黑洞。

Stateful 回退

previous_response_id 这类上游有状态能力可以省 token,但不能成为事实来源。
只有稳定前缀一致时,才使用上游状态。
Codego
func CanUsePrevious(prevID, storedFP, currentFP string) bool {
	return prevID != "" && storedFP != "" && storedFP == currentFP
}
fingerprint 至少覆盖:
  • 模型和路由
  • 系统策略
  • 稳定文件上下文
  • 工具定义
  • 关键生成参数
  • 摘要或记忆版本
如果上游拒绝 previous response,退回全量上下文。伪代码如下:
Codego
if can_use_previous_response:
  output = llm.generate(messages=latest_user_only, previous_response_id=prev_id)

  if previous_response_rejected(output.error):
    clear_previous_response_id()
    output = llm.generate(messages=full_context)
else:
  output = llm.generate(messages=full_context)
原则:上游 stateful 是缓存,不是账本。本地必须随时能重建完整请求。

流式恢复

流式输出不能只依赖一条 SSE 连接。连接会断,页面会刷新,用户会切网络。
用 run_id 作为稳定标识,把事件写进短期缓存。
Codego
type StreamEvent struct {
	RunID   string
	Seq     int64
	Type    string
	Payload json.RawMessage
}

type StreamStore interface {
	Append(ctx context.Context, runID string, payload []byte) (int64, error)
	ListAfter(ctx context.Context, runID string, afterSeq int64) ([]StreamEvent, error)
	Cancel(ctx context.Context, runID string) error
	IsCanceled(ctx context.Context, runID string) bool
}
恢复流程:
  1. 前端重连时带 run_id 和 after_seq。
  2. 后端先补发缓存事件。
  3. 再订阅新事件。
  4. 用户点击停止时,标记 run canceled,而不是简单关闭连接。
取消时如果已经生成了部分文本,要落盘并标记 canceled。这比直接丢弃更符合用户预期。

过程追踪

只记录错误日志不够。对话系统需要业务级 trace。
Codego
type TraceEvent struct {
	Seq     int
	Stage   string // prompt / rag / tool / llm / persist
	Status  string // streaming / completed / error
	Title   string
	Summary string
	Payload map[string]any
}
至少记录这些信息:
阶段
应记录内容
Prompt
full/stateful、消息数、token、上下文来源
RAG
query、命中数、最高分、失败原因、回退策略
Tool
工具名、参数摘要、状态、耗时、是否复用
LLM
模型、路由、首 token 延迟、usage
Persist
消息状态、run 状态、错误码
不要把完整 prompt 当作唯一观测手段。更好的方式是记录 prompt shape:
Codego
type PromptShape struct {
	Mode         string // full / stateful / full_retry
	MessageCount int
	Tokens       int64
	HasFiles     bool
	HasRAG       bool
	HasMemory    bool
	HasTools     bool
}
这样排查问题时,可以快速判断:这一轮到底是上下文过大、RAG 没命中、工具循环太长,还是上游 stateful 回退导致延迟上升。

异步边界

生成标题、消息向量化、会话摘要、长期记忆更新都可以异步。但核心事实必须同步完成。
同步路径必须完成:
  • 用户消息状态
  • 助手消息状态
  • run 状态
  • 工具调用记录
  • 关键 token 和延迟
  • 错误码和错误摘要
异步任务只做增强能力。主流程可以按下面的伪代码收口:
Codego
persist_success(result):
  update_assistant_message(result.answer)
  create_tool_call_records(result.tool_calls)
  update_run(status=success, usage=result.usage)

  async_with_timeout("embed_messages"):
    index_message_pair(result.messages)

  async_with_timeout("generate_title"):
    generate_conversation_title(result.conversation_id)
异步任务要有超时、panic recovery 和错误日志。它们可以失败,但不能影响本轮对话事实。

请求链路

链路图
图里省略了路由熔断、计费 ledger、文件处理超时、RAG 低分回退、trace 可见性开关等细节,但保留了最关键的结构:分支决定上下文路径,上下文路径决定 PromptPlan,PromptPlan 再进入生成和工具闭环。
真正落代码时,每个节点都要回答三个问题:
  • 输入是什么?
  • 失败怎么办?
  • 如何追踪?
回答不了的地方,就是系统未来最容易失控的地方。

结语

多轮对话的可靠性,不来自某个更长的 prompt,也不来自单个更强的模型。它来自一组清晰的工程约束:
  • 用 Run 和消息状态机承接请求。
  • 用消息树路径构建上下文。
  • 用 PromptPlan 管理上下文来源和预算。
  • 把 RAG、工具、stateful 会话设计成可失败、可回退的子流程。
  • 让流式输出支持恢复和取消。
  • 把 trace 做成业务证据。
  • 把异步任务放到关键路径之外。
这些设计在 demo 阶段看起来偏重,但进入生产后,它们决定系统能否解释失败、控制成本、恢复现场,并持续迭代。
文章不错?点赞鼓励一下吧

0 条评论

参与讨论

请登录后发表您的评论

还没有评论,来抢个沙发吧。

目录

设计目标设计思路状态先行路径上下文Prompt 规划证据隔离记忆分层RAG 降级工具闭环Stateful 回退流式恢复过程追踪异步边界请求链路结语

数据

阅读量

71

点赞数

0

转发数

2

阅读时长

8 分钟

发布时间

2026/05/14

最近更新

2026/05/14

分享