From 47e60187c73fc2d29488979873ba9c2f7feb350f Mon Sep 17 00:00:00 2001 From: SkyLi Date: Sat, 26 Sep 2026 20:36:05 +0800 Subject: [PATCH] feat: raise configurable chat tool call limit --- .env.example | 1 + backend/tests/test_chat_agent.py | 37 +++++++++++++++++++++++++++++--- backend/wisegrade/chat_api.py | 17 +++++++++------ backend/wisegrade/config.py | 2 ++ docs/CHAT_AGENT.md | 2 +- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 2c21fe9..da83a58 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,4 @@ OCR_MODEL_DIR=/models/ocr MODEL_TIMEOUT_SECONDS=120 MODEL_CONCURRENCY=2 MODEL_RETRIES=2 +CHAT_MAX_TOOL_CALLS=32 diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index de24753..e102ffa 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -218,8 +218,9 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): def tool_call(self, messages, tools): calls.append(messages) if len(calls) == 1: - return {"role": "assistant", "content": None, "tool_calls": [{"id": "call1", "type": "function", - "function": {"name": "list_courses", "arguments": "{}"}}]} + return {"role": "assistant", "content": None, "tool_calls": [ + {"id": f"call{index}", "type": "function", + "function": {"name": "list_courses", "arguments": "{}"}} for index in range(9)]} return {"role": "assistant", "content": "已列出课程版本。", "tool_calls": []} monkeypatch.setattr(chat_api, "client_for_chat", lambda db: FakeClient()) def override_db(): @@ -231,9 +232,10 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): created = client.post("/api/conversations", json={"title": "测试对话"}).json() with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "列出课程"}) as response: body = "".join(response.iter_text()) - assert "event: tool" in body and "event: message" in body + assert body.count("event: tool") == 9 and "event: message" in body restored = client.get(f"/api/conversations/{created['id']}").json() assert [m["role"] for m in restored["messages"]] == ["user", "assistant"] + assert len(restored["messages"][1]["tool_calls"]) == 9 assert restored["runs"][0]["status"] == "completed" monkeypatch.setattr(chat_api, "client_for_chat", lambda db: (_ for _ in ()).throw(ModelError("不支持工具调用"))) with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "再试"}) as response: @@ -251,6 +253,35 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch): app.dependency_overrides.clear() +def test_failed_tool_calls_count_toward_limit(monkeypatch): + class RepeatingClient: + def tool_call(self, messages, tools): + return {"role": "assistant", "content": None, "tool_calls": [ + {"id": f"call{len(messages)}", "type": "function", + "function": {"name": "unknown_tool", "arguments": "{}"}}]} + + conversation = SimpleNamespace(id=1, course_version_id=None, batch_id=None, project_id=None, updated_at=None) + run = SimpleNamespace(id=1, status="running", error=None, finished_at=None) + class FakeSession: + def __enter__(self): + return self + def __exit__(self, *_args): + pass + def get(self, model, _id): + return conversation if model is Conversation else run + def scalars(self, _query): + return SimpleNamespace(all=lambda: []) + def commit(self): + pass + + monkeypatch.setattr(chat_api, "client_for_chat", lambda db: RepeatingClient()) + monkeypatch.setattr(chat_api, "get_settings", lambda: SimpleNamespace(chat_max_tool_calls=9)) + body = "".join(chat_api._run_agent(FakeSession, 1, 1, "循环调用工具")) + assert body.count("event: tool") == 9 + assert "本次消息工具调用超过上限 9 次" in body + assert run.status == "failed" + + def test_native_tool_calls_unsupported_endpoint(): transport = httpx.MockTransport(lambda request: httpx.Response(400, json={"error": "unsupported"})) client = DeepSeekClient(api_key="test-key", transport=transport) diff --git a/backend/wisegrade/chat_api.py b/backend/wisegrade/chat_api.py index 6050576..5cfcd40 100644 --- a/backend/wisegrade/chat_api.py +++ b/backend/wisegrade/chat_api.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker from .actions import action_dict, confirm, prepare, require_scope from .agent_tools import execute, tool_specs +from .config import get_settings from .db import get_db from .llm import ModelError from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now @@ -113,7 +114,10 @@ def _run_agent(factory, conversation_id, run_id, user_text): "project_id": conversation.project_id}, ensure_ascii=False)}] messages.extend({"role": m.role, "content": m.content[:4000]} for m in history) yield _event("run", {"id": run.id, "status": "running"}) - for round_number in range(4): + max_tool_calls = get_settings().chat_max_tool_calls + attempted_calls = 0 + # One extra model response lets the agent answer after its last allowed tool call. + for _ in range(max_tool_calls + 1): reply = client.tool_call(messages, tool_specs()) calls = reply.get("tool_calls") or [] if not calls: @@ -121,16 +125,17 @@ def _run_agent(factory, conversation_id, run_id, user_text): if not isinstance(content, str) or not content.strip(): raise ModelError("对话模型未返回文字或工具调用") db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content, - citations=citations[:20], tool_calls=used[:8])) + citations=citations[:20], tool_calls=used)) run.status, run.finished_at = "completed", now() conversation.updated_at = now() db.commit() completed = True - yield _event("message", {"content": content, "citations": citations[:20], "tool_calls": used[:8]}) + yield _event("message", {"content": content, "citations": citations[:20], "tool_calls": used}) yield _event("done", {"run_id": run.id}) return - if len(calls) + len(used) > 8: - raise ModelError("单轮工具调用超过上限 8 次") + if attempted_calls + len(calls) > max_tool_calls: + raise ModelError(f"本次消息工具调用超过上限 {max_tool_calls} 次") + attempted_calls += len(calls) messages.append({"role": "assistant", "content": reply.get("content"), "tool_calls": calls}) for call in calls: name = call.get("function", {}).get("name", "") @@ -155,7 +160,7 @@ def _run_agent(factory, conversation_id, run_id, user_text): messages.append({"role": "tool", "tool_call_id": call_id, "content": json.dumps({"error": error[:500]}, ensure_ascii=False)}) yield _event("tool", {"name": name, "status": "failed", "error": error[:500]}) - raise ModelError("模型工具调用轮数超过上限 4 次") + raise ModelError(f"本次消息模型调用超过上限 {max_tool_calls + 1} 轮") except Exception as exc: run.status, run.error, run.finished_at = "failed", str(exc)[:500], now() db.commit() diff --git a/backend/wisegrade/config.py b/backend/wisegrade/config.py index 4d46579..72ea130 100644 --- a/backend/wisegrade/config.py +++ b/backend/wisegrade/config.py @@ -1,6 +1,7 @@ from functools import lru_cache from pathlib import Path +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -23,6 +24,7 @@ class Settings(BaseSettings): model_timeout_seconds: int = 120 model_concurrency: int = 2 model_retries: int = 2 + chat_max_tool_calls: int = Field(default=32, ge=1, le=64) @lru_cache diff --git a/docs/CHAT_AGENT.md b/docs/CHAT_AGENT.md index 506777a..881292c 100644 --- a/docs/CHAT_AGENT.md +++ b/docs/CHAT_AGENT.md @@ -26,7 +26,7 @@ 对话使用模型设置页的 OpenAI 兼容 `/chat/completions` 地址、模型和后端 API Key。适配层按 [DeepSeek 原生工具调用文档](https://api-docs.deepseek.com/guides/tool_calls) 发送 `tools`,在每轮前用不含学生资料的 `wisegrade_ping` 最小探针验证端点会返回 `tool_calls`。当前 `deepseek-flash` 端点支持自动工具选择,但拒绝强制 `tool_choice="required"`,因此探针采用 `auto` 并检查返回结构。模型不支持时会显示明确配置错误,不会伪装工具已执行。既有评分 `json_call` 保持原有 JSON 行为。 -工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每轮最多 8 次工具调用、4 轮模型调用;工具输出和会话上下文限量。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。 +工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每条教师消息默认最多调用工具 32 次,成功与失败的尝试均计数;模型最多请求 33 轮,以便用完工具次数后还能生成回答。可在 `.env` 中通过 `CHAT_MAX_TOOL_CALLS` 调整工具上限(1–64),修改后重新创建 API 容器。工具输出和会话上下文限量。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。 ## API 与故障恢复