Files
AgentLab/backend/tests/test_copilot_truncation.py
ThearchyHelios 6b68961c65 助手把操作写成工具调用格式时照操作读;一个能落地的操作都没有时当场纠正一次;报错按诊断照实说
部署上画布助手连着几轮报「助手本轮未完成:模型没有给出任何修改,也没有说明原因」,又试了一次才成。本地用同一网关、同一模型、同一底图和需求重放复现:模型思考里写着「先输出 look」,正文却是一段 DeepSeek 的 DSML 工具调用(invoke name="look",参数 need、targets),意图和参数都对,只是不是约定的单行 JSON。读流只认 JSON 和原生工具调用,于是一个操作都没认出、整轮报错;画布不处理 next=retry,点重试原样再发,几率不变。提示词里「输出这一行就停下,系统把结果交回来」很像工具调用,却从没说这里没有工具调用接口。

- engine/toolcalls:take_leaked_calls 读写成文字的工具调用(DSML 竖线一到两个、带不带空格,<function_calls><invoke>,<tool_call>{name, arguments},DeepSeek 特殊 token 两种排法),按出现先后交出已写完的调用和没写完的那一段;final 时参数写完整、只差结尾的补上结尾再读,写到一半的不硬读。_MARKUP 补认 DSML 结尾标签和 function_calls / tool_calls 包装(agent 节点的识别跟着受益)。has_markup 只认标记本身
- copilot_gen._iter_ops:从有标记的那一行起攒,一次调用写完就按名字读成操作(名字是操作名的才认,规矩同原生工具调用 _call_op;op 写在最前,接着写时交回模型的是约定写法),读到 look 照样停下去查看;读不出的记「工具调用标记的名字不认识 / 参数解析不了 / 没写完」;raw 交出正文开头一段
- Generation:起草时一次调用读完、到这时还没有能落地的操作(plan、look 不算——查看完接着写什么都没认出的整轮照样是空的),把原话交回去、附 StreamDiagnosis.correction 那句纠正,当场重写一次(FORMAT_NUDGE_PROMPT,每轮一次);什么都没写的把纠正接在最后那条消息后面。诊断按次收(absorb),记 nudged。写成标记被读出、当场纠正都打 info 日志
- 报错说法:Generation.empty 默认用 StreamDiagnosis.headline(写成工具调用格式 / 只写了思考 / 什么都没有输出 / 格式不对 / 只说了打算),不再一律「也没有说明原因」;摘要注明当场纠正过
- 提示词:look 用法(四个入口共用)和画布、应用、蓝图的输出格式都写明这里没有工具调用接口
- 问数据失败原因 meta.cause(kind=copilot)带上 code、diagnostics

验证:本地真模型重放,修后 DSML 没再出现、都正常交付;把第一次调用换成读不出的 DSML 后交给真模型,都被当场纠正救回来(先 look 再出操作);原先抓到的真实 DSML 原文离线读成 look。测试:新增 tests/test_copilot_markup_ops.py(各写法、流式切碎、和 JSON 行混写、读不出的诊断、DSML look 驱动真查看、说明文字 / 只写思考 / 只写 plan / 查看后空续写的纠正、只纠正一次、画布流不报错、报错说法、提示词);空生成相关的旧测试按「一次生成先纠正一次」更新(基线夹具 no_ops 的说法)。后端全量通过。
2026-10-19 22:48:23 +08:00

313 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Copilot 没说完就不能按「无需修改」交付。
2026-09 部署实例上,复杂的改图需求(并行算同比、环比,再加一个单独的 agent)连续两次得到
「已检查,画布无需修改 · 自查通过」。按同一模型、同一张底图、同一句指令重放:输出额度 8192
全部用在思考上(finish_reason=length),正文一个字都没有。读流时不看 finish_reason,也不要求
收到 done,于是自查对着原图报「通过」,final 交回原图,前端比出来没变化就说「无需修改」。
这里守几件事:
- 输出被截断(OpenAI 系的 finish_reason=length、Anthropic 的 stop_reason=max_tokens)要报错,
不跑自查、不交 final;
- 流正常结束却一个操作都没给、也没有 done(格式跑偏,全被跳过了)同样报错;
- 模型明确说完了(有 done)但没改任何东西,照旧交付——那才是真的无需修改;
- 改图的额度要够思考之后还写得下操作;服务方一开口就嫌额度太大时退回 8192 重来。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessageChunk
from app.api import copilot as cp
from app.api.copilot import _iter_ops
from app.main import app
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def _node(nid: str, ntype: str, config: dict) -> dict:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
#: 一张本来就能过自查的图:截断时自查对着它报「通过」,正是那次的样子
BASE = {
"nodes": [
_node("start", "input", {"fields": [{"name": "question"}]}),
_node("answer", "llm", {"prompt": "{{ input.question }}", "assign_to": "answer"}),
_node("out", "output", {"fields": [{"name": "答案", "value": "{{ vars.answer }}"}]}),
],
"edges": [{"source": "start", "target": "answer"}, {"source": "answer", "target": "out"}],
}
def _line(op: dict) -> AIMessageChunk:
return AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
def _thinking(text: str) -> AIMessageChunk:
# OpenAI 兼容系(DeepSeek 经 LiteLLM)的思考走 reasoning_content
return AIMessageChunk(content="", additional_kwargs={"reasoning_content": text})
def _openai_cut() -> AIMessageChunk:
return AIMessageChunk(content="", response_metadata={"finish_reason": "length"})
def _anthropic_cut() -> AIMessageChunk:
return AIMessageChunk(content=[], response_metadata={"stop_reason": "max_tokens"})
class _Scripted:
"""按剧本吐 chunk 的模型。each 是每次调用吐的那一串;调用次数多于剧本时重复最后一串。"""
def __init__(self, *each: list[AIMessageChunk], max_tokens: int | None = None) -> None:
self.each = each
self.calls = 0
self.max_tokens = max_tokens
async def astream(self, messages):
chunks = self.each[min(self.calls, len(self.each) - 1)]
self.calls += 1
for chunk in chunks:
yield chunk
async def _events(client, monkeypatch, model, base: dict | None = BASE) -> list[dict]:
async def _model(*_a, **_k):
return model, "mock-fast"
monkeypatch.setattr(cp, "get_chat_model", _model)
out: list[dict] = []
async with client.stream("POST", "/api/copilot/generate-stream",
json={"instruction": "并行算同比和环比", "base_graph": base}) as r:
async for line in r.aiter_lines():
# 末尾那条 usage 是这一轮助手的用量(answer_ops),不是生成的操作
if line.startswith("data: ") and not line.startswith('data: {"op": "usage"'):
out.append(json.loads(line[6:]))
return out
def _kinds(events: list[dict]) -> list[str]:
return [e["op"] for e in events if e["op"] not in ("thinking", "heartbeat", "model")]
# ---------------------------------------------------------------------------
# 读流这一层
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("cut", [_openai_cut, _anthropic_cut], ids=["finish_reason", "stop_reason"])
async def test_a_cut_off_stream_raises(cut):
model = _Scripted([_thinking("先想想怎么拆成并行分支……"), _line({"op": "plan", "summary": "拆成三支"}),
AIMessageChunk(content='{"op":"add_node","node":{"id":"yo'), cut()])
got: list[dict] = []
with pytest.raises(cp.CopilotIncomplete) as info:
async for op in _iter_ops(model, []):
got.append(op)
assert [o["op"] for o in got] == ["thinking", "plan"], "截断之前的操作照常转出去"
assert "长度上限" in str(info.value)
assert info.value.hint
async def test_done_that_lands_exactly_at_the_limit_is_complete():
"""最后一行 done 刚好写完(没来得及换行)就到了上限:模型已经说完了,不算截断。"""
model = _Scripted([_line({"op": "plan", "summary": "x"}),
AIMessageChunk(content='{"op":"done","explanation":"好了"}'), _openai_cut()])
ops = [o async for o in _iter_ops(model, [])]
assert [o["op"] for o in ops] == ["plan", "done"]
async def test_a_normal_finish_is_not_a_cut():
model = _Scripted([_line({"op": "plan", "summary": "x"}), _line({"op": "done", "explanation": "y"}),
AIMessageChunk(content="", response_metadata={"finish_reason": "stop"})])
ops = [o async for o in _iter_ops(model, [])]
assert [o["op"] for o in ops] == ["plan", "done"]
# ---------------------------------------------------------------------------
# 画布改图:截断和空手而归都不能冒充「无需修改」
# ---------------------------------------------------------------------------
async def test_thinking_that_eats_the_whole_budget_is_an_error_not_unchanged(client, monkeypatch):
"""就是截图里那一轮:只有思考、没有正文,额度用完。"""
model = _Scripted([_thinking("Let me design the modification. "), _thinking("report_ans has 4 incoming edges."),
_openai_cut()], max_tokens=8192)
events = await _events(client, monkeypatch, model)
kinds = _kinds(events)
assert "final" not in kinds, "没说完的一轮不能把原图当成结果交回去"
assert "check" not in kinds, "自查对着原图报「通过」,是在替一个没发生的修改作证"
[err] = [e for e in events if e["op"] == "error"]
assert err["message"].startswith("助手本轮未完成:") and "长度上限" in err["message"]
assert "画布未修改" in err["hint"]
assert "length" in err["detail"] and "8192" in err["detail"]
async def test_a_cut_in_the_middle_of_the_ops_is_not_delivered_as_done(client, monkeypatch):
"""写到一半被截断:半张图不能当成品交付,更不能自动开跑。"""
model = _Scripted([
_line({"op": "plan", "summary": "加一支"}),
_line({"op": "add_node", "node": {"id": "yoy", "type": "llm", "label": "同比",
"config": {"prompt": "算同比", "assign_to": "yoy"}}}),
_anthropic_cut(),
])
events = await _events(client, monkeypatch, model)
kinds = _kinds(events)
assert "final" not in kinds
assert kinds[-1] == "error"
async def test_output_that_parses_to_nothing_is_an_error(client, monkeypatch):
"""格式跑偏(只写了说明文字、没有一行是操作),每一行都被跳过:一个操作都没有,也没有 done。当场带纠正重写一次还是这样,
才报错;诊断写明跳过了几行(两次的)。"""
model = _Scripted([AIMessageChunk(content="先看一下表结构,\n再决定怎么搭。\n"),
AIMessageChunk(content="", response_metadata={"finish_reason": "stop"})])
events = await _events(client, monkeypatch, model)
kinds = _kinds(events)
assert "final" not in kinds
[err] = [e for e in events if e["op"] == "error"]
assert err["message"].startswith("助手本轮未完成:") and "画布未修改" in err["hint"]
assert "跳过 4 行:4 行不是完整的 JSON" in err["detail"] and "先看一下表" not in err["detail"], err
assert err["message"] == "助手本轮未完成:模型的输出格式不对,没有一行能认出的操作" and model.calls == 2, err
async def test_a_pretty_printed_op_in_a_fence_is_read(client, monkeypatch):
"""整段放进 markdown 围栏、JSON 分了行:以前每一行都被跳过、报一个操作都没有;现在攒成一个操作照常落地。"""
pretty = "```json\n" + json.dumps({"op": "add_node", "node": {"id": "yoy", "type": "llm", "label": "同比",
"config": {"prompt": "算同比"}}},
indent=2, ensure_ascii=False) + "\n```\n"
model = _Scripted([AIMessageChunk(content=pretty),
AIMessageChunk(content="", response_metadata={"finish_reason": "stop"})])
events = await _events(client, monkeypatch, model)
assert "error" not in _kinds(events), events
assert any(e["op"] == "add_node" and e["node"]["id"] == "yoy" for e in events)
async def test_a_model_that_says_done_without_changes_is_still_unchanged(client, monkeypatch):
"""模型看过、明确收尾、一处不改:这才是真的「无需修改」,照旧交付。"""
model = _Scripted([_line({"op": "plan", "summary": "现有的图已经满足"}),
_line({"op": "done", "explanation": "不需要改"})])
events = await _events(client, monkeypatch, model)
final = events[-1]
assert final["op"] == "final" and final["explanation"] == "不需要改"
assert {n["id"] for n in final["graph"]["nodes"]} == {"start", "answer", "out"}
assert "error" not in _kinds(events)
async def test_a_cut_in_the_repair_round_is_reported(client, monkeypatch):
"""自查交回去修的那一轮被截断:要说修正没完成,不能说「自查通过」。"""
broken = [
_line({"op": "plan", "summary": "加个循环"}),
_line({"op": "add_node", "node": {"id": "lp", "type": "loop", "label": "循环",
"config": {"mode": "while", "condition": "foo(vars.x)",
"max_iterations": 2}}}),
_line({"op": "add_edge", "edge": {"source": "answer", "target": "lp"}}),
_line({"op": "add_edge", "edge": {"source": "lp", "target": "out", "sourceHandle": "done"}}),
_line({"op": "done", "explanation": "加好了"}),
]
model = _Scripted(broken, [_thinking("修一下条件"), _openai_cut()])
events = await _events(client, monkeypatch, model)
checks = [e for e in events if e["op"] == "check"]
assert checks[0]["status"] == "repairing"
assert any(c["status"] == "error" and "长度上限" in c["message"] for c in checks), checks
assert not any(c["status"] == "passed" for c in checks)
# ---------------------------------------------------------------------------
# 额度
# ---------------------------------------------------------------------------
async def test_the_stream_asks_for_a_budget_that_leaves_room_after_thinking(client, monkeypatch):
asked: list[int | None] = []
async def _model(_session, spec):
asked.append(spec.max_tokens)
return _Scripted([_line({"op": "plan", "summary": "x"}), _line({"op": "done", "explanation": "y"})]), "m"
monkeypatch.setattr(cp, "get_chat_model", _model)
async with client.stream("POST", "/api/copilot/generate-stream",
json={"instruction": "改一下", "base_graph": BASE}) as r:
[line async for line in r.aiter_lines()]
assert asked and asked[0] >= 32768, asked
class _BudgetTooLarge(Exception):
"""仿 openai / anthropic SDK 的 400:一发请求就被拒,还没吐出任何东西。"""
status_code = 400
class _Rejects:
def __init__(self, message: str) -> None:
self.message = message
self.calls = 0
async def astream(self, messages):
self.calls += 1
raise _BudgetTooLarge(self.message)
yield # pragma: no cover - 让它成为异步生成器
@pytest.mark.parametrize("message", [
"Invalid max_tokens value, the valid range of max_tokens is [1, 8192]",
"max_tokens: 32768 > 8192, which is the maximum allowed number of output tokens",
"This model's maximum context length is 32768 tokens. However, you requested 40369 tokens "
"(7601 in the messages, 32768 in the completion).",
])
async def test_a_rejected_budget_falls_back_to_the_old_one(client, monkeypatch, message):
big = _Rejects(message)
small = _Scripted([_line({"op": "plan", "summary": "x"}),
_line({"op": "add_node", "node": {"id": "yoy", "type": "llm", "label": "同比",
"config": {"prompt": "算同比", "assign_to": "yoy"}}}),
_line({"op": "add_edge", "edge": {"source": "answer", "target": "yoy"}}),
_line({"op": "add_edge", "edge": {"source": "yoy", "target": "out"}}),
_line({"op": "done", "explanation": "加好了"})])
asked: list[int | None] = []
async def _model(_session, spec):
asked.append(spec.max_tokens)
return (big if spec.max_tokens and spec.max_tokens > 8192 else small), "m"
monkeypatch.setattr(cp, "get_chat_model", _model)
events: list[dict] = []
async with client.stream("POST", "/api/copilot/generate-stream",
json={"instruction": "加一支同比", "base_graph": BASE}) as r:
async for line in r.aiter_lines():
# 末尾那条 usage 是这一轮助手的用量(answer_ops),不是生成的操作
if line.startswith("data: ") and not line.startswith('data: {"op": "usage"'):
events.append(json.loads(line[6:]))
assert 8192 in asked
assert big.calls == 1 and small.calls == 1
final = events[-1]
assert final["op"] == "final" and "yoy" in {n["id"] for n in final["graph"]["nodes"]}
async def test_other_rejections_are_not_retried(client, monkeypatch):
"""别的 400(模型名写错之类)照实报错,不拿小额度再撞一次。"""
big = _Rejects("model 'no-such-model' does not exist")
small = _Scripted([_line({"op": "done", "explanation": "不该到这里"})])
async def _model(_session, spec):
return (big if spec.max_tokens and spec.max_tokens > 8192 else small), "m"
monkeypatch.setattr(cp, "get_chat_model", _model)
events: list[dict] = []
async with client.stream("POST", "/api/copilot/generate-stream",
json={"instruction": "改一下", "base_graph": BASE}) as r:
async for line in r.aiter_lines():
# 末尾那条 usage 是这一轮助手的用量(answer_ops),不是生成的操作
if line.startswith("data: ") and not line.startswith('data: {"op": "usage"'):
events.append(json.loads(line[6:]))
assert small.calls == 0
assert events[-1]["op"] == "error"
def test_the_protocol_asks_not_to_draft_the_json_while_thinking():
"""思考里把整段 JSON 先写一遍,额度就去了一半:那一轮重放时思考的结尾正在逐字写口径卡的表达式。"""
assert "思考" in cp._STREAM_PROTOCOL and "不要在思考里" in cp._STREAM_PROTOCOL