不是每句话都值得跑一遍库:模型自己选要不要执行

以前必执行——final 一到就无条件 launch。库里那条会话 7 轮全建了图、全跑了
一遍 153 张表的库,其中:

- 「重试」「继续找找」这种对过程说的话,各自重建了一整张图去查
- 一句明确要「设计一个工作流,使得每次执行的时候都给出管理员账户信息」的,
  建完也直接跑了——用户要的是那张图,不是这一次的结果

流协议给模型两个新出口,三条路径二选一:

- {"op":"reply","text":"…"}  不建图不跑。问的是前几轮已经查出来的东西
  (口径、怎么算的),或者是对过程说的话,或者需要先反问清楚
- done 里 run:false          建图但停住,界面上给「跑一下」
- done 里 run:true(默认)   不变

reply 一到服务端就收流:让它走到后面那套排版校验上,空图会撞出"图校验未
通过:图是空的,先拖一个节点进来"——上一个提交刚治过同类的坑。

放开「直接回答」等于绕开了这条路径上最硬的一条约定(涉及数据必须真查),
所以两头都上:prompt 里写死 reply 只能复述或解释前面几轮已有的结果、不得
新断言任何数据事实,凡是要动到库才知道的哪怕觉得上一轮见过也重新查;界面
上那一轮的结论**前面**挂一条声明,说清楚这一条没有查库。check-stream 里有
一条断言专门盯它排在结论前面——排在后面等于先让人读完再说来源不可靠。

需要留意:这依赖模型照约定表态,不是硬规则。判断跑偏的话收紧那几句约定。
This commit is contained in:
ThearchyHelios
2026-09-18 22:30:45 +08:00
parent 152264b29c
commit cd7ced7673
7 changed files with 202 additions and 10 deletions
+33 -3
View File
@@ -248,7 +248,21 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str:
"- 但**每个节点都要用 add_node 完整输出一遍**,包括没有改动的。"
"这一轮是从空图开始搭的,只发改动操作会得到一张空图\n"
"- 之前几轮的问答由系统注入成 `{{ input.history }}`。用户用「它们」「这些」"
"这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据"
"这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据\n"
"\n"
"先判断这句话该走哪条路径(三选一):\n"
# 以前这里没有分叉:每一句话都建一张图、跑一遍库。于是「重试」
# 「继续找找」这种对过程说的话,也各自重建了一整张图去查 153 张表
"1. **直接回答**(输出 reply):这句话不需要新数据就能答——问的是"
"前面几轮已经查出来的东西(口径、怎么算的、哪张表)、是对过程说的话"
"(「重试」「换个说法」),或者你需要先反问清楚才能动手。\n"
" 硬性约束:reply 里**只能复述或解释前面几轮已有的结果**,"
"不得新断言任何数据事实——不许凭印象给数字、给名单、给结论。"
"凡是要动到库才知道的,哪怕你觉得上一轮见过,也走第 2 条重新查。\n"
"2. **建图并执行**(done 的 run 为 true,默认):要新数据才能答的问题。\n"
"3. **建图但不执行**(done 的 run 为 false):用户要的是**流程本身**"
"——「设计一个工作流」「做一个每天能跑的」这类。他要的是这张图,"
"不是这一次的结果,跑一遍只是替他多花一次钱。\n"
)
return f"请设计一个工作流:{payload.instruction}"
@@ -516,9 +530,14 @@ _STREAM_PROTOCOL = """\
{"op":"remove_node","id":"..."}
{"op":"add_edge","edge":{"source":"...","target":"...","sourceHandle":"..."}}
{"op":"remove_edge","source":"...","target":"...","sourceHandle":"..."}
{"op":"done","explanation":"两三句话说明这张图怎么跑"} ← 最后一行
{"op":"done","explanation":"两三句话说明这张图怎么跑","run":true} ← 最后一行
- 按执行顺序添加节点(先入口后出口);每加一个节点,立刻把连向它的边(两端都已存在的)输出出来,让图连贯地生长
- 修改现有图时只输出改动,没提到的节点不要动"""
- 修改现有图时只输出改动,没提到的节点不要动
- done 里的 run:这张图建完要不要立刻执行。默认 true;用户要的是**流程本身**
("设计一个每天跑的工作流")时给 false —— 他要的是这张图,不是这一次的结果
- 如果这句话根本不需要工作流(见下面的路径约定),**第一行也是最后一行**就输出:
{"op":"reply","text":"直接回答的内容"}
这时不要输出任何别的操作"""
def _parse_op_line(line: str) -> dict[str, Any] | None:
@@ -720,6 +739,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
async def event_stream():
explanation = ""
seen_ops: list[dict[str, Any]] = []
# 建完要不要跑,由模型在 done.run 里表态
autorun = True
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
try:
async for op in _with_heartbeat(_iter_ops(model, messages)):
@@ -728,8 +749,16 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
if kind in ("thinking", "heartbeat"):
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
continue
if kind == "reply":
# 这句话不需要工作流。原样转给前端,然后收流——后面那套
# 排版校验对着一张空图跑,只会得到"图是空的,先拖一个节点进来"
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
return
if kind == "done":
explanation = str(op.get("explanation", ""))
# 用户要的是流程本身时("设计一个每天跑的工作流"),
# 建完就跑等于替他多花一次钱,而他要的是那张图
autorun = op.get("run") is not False
seen_ops.append(op)
changed = _apply_op(nodes, edges, op)
if changed or kind in ("plan", "done"):
@@ -770,6 +799,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
"graph": spec.model_dump(mode="json"),
"issues": issues,
"explanation": explanation,
"autorun": autorun,
}
except Exception as e: # noqa: BLE001
final = {"op": "error", "message": f"生成的图结构非法:{e}"}
+64
View File
@@ -372,3 +372,67 @@ async def test_the_first_turn_has_nothing_to_fall_back_on(client, monkeypatch) -
assert final["op"] == "final"
assert final["graph"]["nodes"] == []
assert any("空" in str(i.get("message", "")) for i in final["issues"]), final["issues"]
# --------------------------------------------------------------------------
# 不是每句话都值得跑一遍库
# --------------------------------------------------------------------------
#
# 以前必执行:final 一到就无条件 launch。于是「重试」「继续找找」这种对过程
# 说的话,也各自重建了一整张图、跑一遍 153 张表的库;而一句明确要「设计一个
# 工作流」的,建完也直接跑了——用户要的是那张图,不是这一次的结果。
async def test_a_question_about_earlier_turns_needs_no_workflow(client, monkeypatch) -> None:
"""reply 这条路径:不建图、不跑、也不该走到那套排版校验上。
走到了的话,空图会撞出"图校验未通过:图是空的,先拖一个节点进来"。
"""
conv_id = await _seeded(client)
final = await _stream_graph(client, monkeypatch, conv_id, [
'{"op":"reply","text":"第 1 轮走的是 role.level IN (platform, regional) 这个口径。"}',
])
assert final["op"] == "reply"
assert "口径" in final["text"]
assert "graph" not in final
async def test_asking_for_a_workflow_builds_it_but_does_not_run_it(client, monkeypatch) -> None:
"""用户要的是流程本身,跑一遍只是替他多花一次钱。"""
conv_id = await _seeded(client)
final = await _stream_graph(client, monkeypatch, conv_id, [
'{"op":"plan","summary":"搭一个每天能跑的"}',
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
'{"op":"done","explanation":"每天跑一次","run":false}',
])
assert final["op"] == "final"
assert final["autorun"] is False
assert [n["id"] for n in final["graph"]["nodes"]] == ["in", "out"]
async def test_a_normal_data_question_still_runs(client, monkeypatch) -> None:
"""默认不变:问数据就是建图 + 跑。没写 run 就当 true。"""
conv_id = await _seeded(client)
final = await _stream_graph(client, monkeypatch, conv_id, [
'{"op":"plan","summary":"查一下"}',
'{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
'{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
'{"op":"add_edge","edge":{"source":"in","target":"out"}}',
'{"op":"done","explanation":"跑完给结论"}',
])
assert final["op"] == "final"
assert final["autorun"] is True
def test_the_three_paths_are_spelled_out_for_the_model() -> None:
"""路径选择全靠这段约定,尤其是那条不许凭印象给数字的硬约束。"""
from app.api.copilot import GenerateIn, _STREAM_PROTOCOL, _user_message
text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False)
assert "直接回答" in text and "建图并执行" in text and "建图但不执行" in text
assert "不得新断言任何数据事实" in text
# 协议里要给得出这两个出口,否则模型没法表达
assert '"op":"reply"' in _STREAM_PROTOCOL
assert '"run":true' in _STREAM_PROTOCOL
+10
View File
@@ -95,6 +95,13 @@ function longReport(): string {
].join("\n")
}
/** 直接回答的那一轮:没查库,界面上必须说破 */
const NO_QUERY_TURN: StreamTurn = {
id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "完成",
steps: [], noQuery: true,
output: { answer: "第 1 轮走的是 `role.level IN (platform, regional)` 这个口径。要换口径我重新查一遍。" },
}
const LONG_TURN: StreamTurn = {
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
steps: [], output: { result: longReport() },
@@ -138,6 +145,9 @@ function Preview() {
if (params.get('long') === '1') {
return <AssistantStream turns={[LONG_TURN]} />
}
if (params.get('noquery') === '1') {
return <AssistantStream turns={[NO_QUERY_TURN]} />
}
return (
<div className="flex h-full flex-col">
+17 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Database, ListTree, MessageSquare, Send, Square } from 'lucide-react'
import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react'
import { api } from '../api/client'
import { ApprovalCard } from '../run/RunPanel'
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
@@ -25,7 +25,7 @@ import { useToast } from '../components/ui'
* dense 一个开关。两边各写一套的话,同一次运行在两个页面会讲出不同的故事。
*/
export function ChatPage() {
const { busy, ask, stop, load, loadSteps, turnsOf } = useChat()
const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat()
const currentId = useConversations((s) => s.currentId)
const create = useConversations((s) => s.create)
const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined))
@@ -73,6 +73,11 @@ export function ChatPage() {
? () => void loadSteps(currentId, t.id)
: undefined
}
onRun={
currentId && turnsOf(currentId).find((x) => x.id === t.id)?.pendingRun
? () => runNow(currentId, t.id)
: undefined
}
/>
)}
onOpenGraph={(graph) => {
@@ -132,6 +137,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
phase: turn.phase === 'waiting' ? 'waiting'
: turn.phase === 'error' ? 'error'
: running ? 'running' : 'done',
// ready(建好了没跑)在流里按"完成"画,差别体现在下面那个「跑一下」上
status: turn.status,
// 建图的步骤在前,跑图的在后——这正是发生的顺序
steps: [...decodeCopilot(turn.ops), ...decodeRun(turn.events)],
@@ -142,6 +148,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
runClass: turn.run?.run_class,
graph: turn.graph,
graphNote: turn.explanation,
noQuery: turn.noQuery,
}
}
@@ -151,19 +158,25 @@ function toStreamTurn(turn: ChatTurn): StreamTurn {
* 从库里恢复的轮次没有事件流——一次会话几十轮,把每轮的事件都预加载回来
* 是几十个请求换一堆没人看的步骤。所以默认只显示问题和答案,想看过程再取。
*/
function Approvals({ turnId, runId, onShowSteps }: {
function Approvals({ turnId, runId, onShowSteps, onRun }: {
turnId: string
runId?: string
onShowSteps?: () => void
onRun?: () => void
}) {
const conversationId = useConversations((s) => s.currentId)
const approvals = useCatalog((s) => s.approvals)
const reattach = useChat((s) => s.reattach)
const pending = approvals.filter((a) => a.run_id === runId && a.status === 'pending')
if (!runId) return null
if (!runId && !onRun) return null
return (
<>
{onRun && (
<button className="btn btn-xs btn-primary mt-2 gap-1 text-[11px]" onClick={onRun}>
<Play size={11} /> 跑一下
</button>
)}
{onShowSteps && (
<button className="btn btn-xs btn-ghost mt-2 gap-1 text-[11px]" onClick={onShowSteps}>
<ListTree size={11} /> 看执行过程
+14 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Brain, ChevronRight, CircleCheck, CircleDot, Database,
FileCode, GitBranch, Hand, Sparkles, Table2, Terminal, Wrench, XCircle,
FileCode, GitBranch, Hand, Info, Sparkles, Table2, Terminal, Wrench, XCircle,
} from 'lucide-react'
import clsx from 'clsx'
import { Spinner } from '../components/ui'
@@ -40,6 +40,8 @@ export interface StreamTurn {
/** 这一轮建出来的图,可展开看、可放到画布 */
graph?: any
graphNote?: string
/** 这一条没有查库,答案是根据前几轮说的 */
noQuery?: boolean
}
const ICONS: Record<StepKind, typeof Wrench> = {
@@ -143,6 +145,17 @@ function TurnCard({ turn, dense, approvals, onOpenGraph }: {
{approvals}
{/* 没查库这件事必须说在结论前面。「涉及数据必须真查」是这条路径上
最硬的一条约定,放开它之后,用户得能一眼分清哪些结论背后真的
动了库、哪些只是在复述前面几轮 */}
{turn.noQuery && (
<div className="mt-2 flex items-start gap-1.5 rounded border px-2 py-1.5 text-[10.5px] leading-relaxed"
style={{ borderColor: 'var(--border)', color: 'var(--text-dim)' }}>
<Info size={11} className="mt-[2px] shrink-0" />
<span>这一条没有查库,是根据前面几轮的结果说的</span>
</div>
)}
{turn.output && (
<Output output={turn.output} dense={dense} runClass={turn.runClass} />
)}
+48 -2
View File
@@ -30,6 +30,7 @@ export type Phase =
| 'building' // 图在长出来
| 'running' // 图在跑
| 'waiting' // 停在人工介入
| 'ready' // 图建好了,等用户决定跑不跑
| 'done'
| 'error'
@@ -61,6 +62,10 @@ export interface ChatTurn {
/** 这一轮自己的取消句柄。放 turn 上而不是 store 上:store 单值会被
* 下一轮 ask 覆盖,上一轮的 WebSocket 就再也关不掉了 */
cancel: (() => void) | null
/** 这一轮没有查库,答案是根据前几轮说的。界面上必须标出来 */
noQuery?: boolean
/** 图建好了但按模型的意思没有自动跑,等用户点「跑一下」 */
pendingRun?: boolean
/** 从库里恢复的历史轮次。它没有事件流,步骤要点开才去取 */
restored?: boolean
/** 已经补取过事件(或正在取),别重复拉 */
@@ -82,6 +87,8 @@ interface ChatState {
reattach: (conversationId: string, turnId: string) => Promise<void>
/** 历史轮次点开"执行过程"时才去取事件 */
loadSteps: (conversationId: string, turnId: string) => Promise<void>
/** 模型建了图但没自动跑时,用户点「跑一下」 */
runNow: (conversationId: string, turnId: string) => void
/** 会话被删了,把内存里那一桶也丢掉 */
forget: (conversationId: string) => void
}
@@ -95,6 +102,7 @@ const PHASE_TEXT: Record<Phase, string> = {
building: '正在搭建流程…',
running: '正在执行…',
waiting: '等待你的确认',
ready: '流程搭好了,没有自动执行',
done: '完成',
error: '出错了',
}
@@ -272,14 +280,37 @@ export const useChat = create<ChatState>((set, get) => ({
case 'done':
patch(() => ({ explanation: op.explanation ?? '' }))
break
case 'reply': {
// 这句话不需要工作流——问的是前几轮已经查出来的东西,或者是
// 对过程说的话(「重试」「换个说法」)。以前这些也各自重建一整张
// 图、跑一遍 153 张表的库,纯属白花钱
const text = String(op.text ?? '')
patch(() => ({
phase: 'done', status: PHASE_TEXT.done,
output: { answer: text }, noQuery: true,
}))
set({ busy: false, cancel: null })
void save({ status: 'done', answer: text })
break
}
case 'final': {
// 后端排版校验后的最终图。拿到它就可以直接跑了
const graph = op.graph as GraphSpec
void save({ graph, explanation: op.explanation ?? '' })
// 用户要的是流程本身时(「设计一个每天跑的工作流」),建完就跑
// 等于替他多花一次钱,而他要的是那张图
if (op.autorun === false) {
patch(() => ({
graph, phase: 'ready', status: PHASE_TEXT.ready,
explanation: op.explanation ?? '', pendingRun: true,
}))
set({ busy: false, cancel: null })
void save({ status: 'done' })
break
}
patch(() => ({
graph, phase: 'running', status: PHASE_TEXT.running,
explanation: op.explanation ?? '',
}))
void save({ graph, explanation: op.explanation ?? '' })
void launch(conversationId, id, graph, question, patch, set, save)
break
}
@@ -327,6 +358,21 @@ export const useChat = create<ChatState>((set, get) => ({
}))
},
runNow: (conversationId, turnId) => {
const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId)
if (!turn?.graph || get().busy) return
const patch = patcher(set, conversationId, turnId)
const save = async (body: Record<string, any>) => {
if (!turn.serverId) return
try {
await api.conversations.patchTurn(conversationId, turn.serverId, body)
} catch { /* 存不下不该影响这次运行 */ }
}
patch(() => ({ phase: 'running', status: PHASE_TEXT.running, pendingRun: false }))
set({ busy: true })
void launch(conversationId, turnId, turn.graph, turn.question, patch, set, save)
},
reattach: async (conversationId, turnId) => {
const turn = get().byConversation[conversationId]?.find((t) => t.id === turnId)
if (!turn?.run) return
+16
View File
@@ -139,6 +139,22 @@ console.log('\n=== 长报告的折叠 ===')
await page.close()
}
console.log('\n=== 没查库的那一轮 ===')
{
// 「涉及数据必须真查」是这条路径上最硬的约定,放开直接回答之后,用户得能
// 一眼分清哪些结论背后真的动了库
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`${WEB}/preview.html?noquery=1`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
const body = await page.locator('body').innerText()
check('说破了这一条没有查库', body.includes('没有查库'))
check('声明排在结论前面',
body.indexOf('没有查库') < body.indexOf('role.level'),
`声明@${body.indexOf('没有查库')} 结论@${body.indexOf('role.level')}`)
check('答案本身照常渲染', body.includes('口径'))
await page.close()
}
console.log('\n=== 空态 ===')
{
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })