diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-24 18:11:10 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-24 18:11:10 +0900 |
| commit | f7e2894856c10d8a1e2d429a7858d9f143c42404 (patch) | |
| tree | fc916d63947acead39868968db26ecd80854579c /src/ollama_web | |
| parent | 374ba05fe2a4022d95ed45322a13e9eef3ed59de (diff) | |
Web検索などのツール呼び出しによる応答不能を回避
Diffstat (limited to 'src/ollama_web')
| -rw-r--r-- | src/ollama_web/config.py | 1 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 166 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 15 | ||||
| -rw-r--r-- | src/ollama_web/static/app.js | 32 | ||||
| -rw-r--r-- | src/ollama_web/tools/pdf.py | 3 |
5 files changed, 211 insertions, 6 deletions
diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py index 44947dd..7b6d8f9 100644 --- a/src/ollama_web/config.py +++ b/src/ollama_web/config.py @@ -28,6 +28,7 @@ class Settings: search_max_results: int = 5 fetch_max_results: int = 3 fetch_max_chars: int = 8000 + max_tool_result_chars: int = 3000 settings = Settings() diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py index 92f4ce5..40af2d4 100644 --- a/src/ollama_web/llm.py +++ b/src/ollama_web/llm.py @@ -13,15 +13,31 @@ from .config import settings from .tools.registry import ToolRegistry, default_registry # Maximum successive tool-call rounds before we force a final answer. +# Some models request many tools per round; we keep this bounded but large +# enough to let iterative research complete (e.g. search, then fetch). MAX_TOOL_ROUNDS = 5 +# Upper bound on the cumulative size of tool results sent back to ollama. +# Larger values can cause ollama to spend a very long time processing the +# context between rounds. +MAX_TOOL_CONTEXT_CHARS = 24000 + # Type alias matching ollama's expected tools signature. ToolCallable = Callable[..., Any] def get_client(host: str | None = None) -> ollama.Client: """Return an ``ollama.Client`` configured for the configured host.""" - return ollama.Client(host=host or settings.ollama_host, timeout=settings.ollama_timeout) + import httpx + + timeout = httpx.Timeout( + settings.ollama_timeout, + connect=10.0, + read=settings.ollama_timeout, + write=10.0, + pool=10.0, + ) + return ollama.Client(host=host or settings.ollama_host, timeout=timeout) def list_models(host: str | None = None) -> list[str]: @@ -106,13 +122,27 @@ def chat_with_tools( The final assistant message is streamed token-by-token via ``delta`` events. """ + import logging + + logger = logging.getLogger("ollama_web.llm") + raw_client = get_client(host) reg = registry or default_registry() tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables] + cumulative_tool_chars = 0 + rounds = 0 while rounds < MAX_TOOL_ROUNDS: rounds += 1 + logger.info( + "tool round %d/%d start messages=%d model=%s cumulative_tool_chars=%d", + rounds, + MAX_TOOL_ROUNDS, + len(messages), + model, + cumulative_tool_chars, + ) try: resp = cast(Any, raw_client).chat( model=model, @@ -122,6 +152,7 @@ def chat_with_tools( stream=False, ) except Exception as exc: # noqa: BLE001 + logger.warning("ollama chat call failed: %s", exc) yield {"type": "error", "message": str(exc)} return @@ -141,15 +172,48 @@ def chat_with_tools( messages.append(_assistant_message(resp)) # Execute each requested tool and append tool-role messages. + tool_results: list[tuple[str, str]] = [] for call in calls: name = call.get("name", "") args = call.get("arguments", {}) yield {"type": "tool_start", "name": name, "arguments": args} result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args)) + result = _trim_tool_result(result, settings.max_tool_result_chars) yield {"type": "tool_end", "name": name, "result": result} + tool_results.append((name, result)) + + if sum(len(r) for _, r in tool_results) > MAX_TOOL_CONTEXT_CHARS: + budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200 + max_per_tool = budget // max(1, len(tool_results)) + logger.warning( + "tool results too large (%d chars), truncating to %d chars per tool", + sum(len(r) for _, r in tool_results), + max_per_tool, + ) + tool_results = [(n, _trim_tool_result(r, max_per_tool)) for n, r in tool_results] + + cumulative_tool_chars += sum(len(r) for _, r in tool_results) + for name, result in tool_results: messages.append(Message(role="tool", tool_name=name, content=result)) # Exceeded tool rounds: ask for a final answer without tools. + logger.info( + "max tool rounds exceeded, requesting final answer without tools (cumulative_tool_chars=%d)", + cumulative_tool_chars, + ) + yield { + "type": "status", + "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…", + } + messages.append( + Message( + role="user", + content=( + "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、" + "ユーザーが求めた回答を最終的にまとめてください。" + ), + ) + ) try: resp = cast(Any, raw_client).chat( model=model, @@ -158,6 +222,7 @@ def chat_with_tools( stream=False, ) except Exception as exc: # noqa: BLE001 + logger.warning("final ollama chat call failed: %s", exc) yield {"type": "error", "message": str(exc)} return content = _assistant_content(resp) @@ -169,6 +234,18 @@ def chat_with_tools( yield {"type": "done"} +def _trim_tool_result(result: str, limit: int) -> str: + """Truncate ``result`` to ``limit`` chars while keeping it readable.""" + if len(result) <= limit: + return result + # Prefer cutting at a paragraph boundary. + cutoff = result[:limit].rsplit("\n\n", 1)[0] + if len(cutoff) < limit * 0.5: + # No good paragraph boundary; cut at a space instead. + cutoff = result[:limit].rsplit(" ", 1)[0] + return cutoff + "\n\n…[tool result truncated]" + + def stream_chat_with_tools( messages: list[Message], model: str, @@ -183,15 +260,31 @@ def stream_chat_with_tools( events are emitted inline so the UI can render them inside the assistant message. """ + import logging + import time + + logger = logging.getLogger("ollama_web.llm") + raw_client = get_client(host) reg = registry or default_registry() tools: list[ToolCallable] = [cast(Any, c) for c in reg.callables] + cumulative_tool_chars = 0 + rounds = 0 while rounds < MAX_TOOL_ROUNDS: rounds += 1 full_message: dict[str, Any] = {"role": "assistant", "content": "", "thinking": ""} tool_calls: list[dict[str, Any]] = [] + logger.info( + "tool round %d/%d start messages=%d model=%s cumulative_tool_chars=%d", + rounds, + MAX_TOOL_ROUNDS, + len(messages), + model, + cumulative_tool_chars, + ) + t0 = time.time() try: stream = cast(Any, raw_client).chat( model=model, @@ -201,10 +294,13 @@ def stream_chat_with_tools( stream=True, ) except Exception as exc: # noqa: BLE001 + logger.warning("ollama chat call failed in %.2fs: %s", time.time() - t0, exc) yield {"type": "error", "message": str(exc)} return + chunk_count = 0 for chunk in stream: + chunk_count += 1 thinking = _assistant_thinking(chunk) if thinking: full_message.setdefault("thinking", "") @@ -221,6 +317,13 @@ def stream_chat_with_tools( if c not in tool_calls: tool_calls.append(c) + logger.info( + "ollama stream finished round=%d chunks=%d elapsed=%.2fs", + rounds, + chunk_count, + time.time() - t0, + ) + # Reconstruct the assistant message including any tool_calls so the # next round can see them. if tool_calls: @@ -233,17 +336,67 @@ def stream_chat_with_tools( yield {"type": "done"} return + logger.info("executing %d tool call(s) round=%d", len(tool_calls), rounds) + tool_results: list[tuple[str, str]] = [] + total_result_len = 0 for call in tool_calls: name = call.get("name", "") args = call.get("arguments", {}) yield {"type": "tool_start", "name": name, "arguments": args} - result = reg.execute( - name, args if isinstance(args, dict) else json.dumps(args) - ) + t1 = time.time() + try: + result = reg.execute( + name, args if isinstance(args, dict) else json.dumps(args) + ) + except Exception as exc: # noqa: BLE001 + logger.warning("tool %s execution failed in %.2fs: %s", name, time.time() - t1, exc) + result = f"Tool execution failed: {exc}" + # Always truncate per-tool result immediately so it does not bloat + # the context even before we reach the cumulative limit. + result = _trim_tool_result(result, settings.max_tool_result_chars) + logger.info("tool %s executed len=%d elapsed=%.2fs", name, len(result), time.time() - t1) yield {"type": "tool_end", "name": name, "result": result} + tool_results.append((name, result)) + total_result_len += len(result) + + # If the accumulated tool results are still too large, truncate each + # one more aggressively to keep ollama's context processing fast. + if total_result_len > MAX_TOOL_CONTEXT_CHARS: + budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200 # reserve footer per tool + max_per_tool = budget // max(1, len(tool_results)) + logger.warning( + "tool results too large (%d chars), truncating to %d chars per tool", + total_result_len, + max_per_tool, + ) + trimmed_results: list[tuple[str, str]] = [] + for name, result in tool_results: + trimmed_results.append((name, _trim_tool_result(result, max_per_tool))) + tool_results = trimmed_results + + cumulative_tool_chars += sum(len(r) for _, r in tool_results) + for name, result in tool_results: messages.append(Message(role="tool", tool_name=name, content=result)) # Exceeded tool rounds: ask for a final answer without tools. + logger.info( + "max tool rounds exceeded, requesting final answer without tools (cumulative_tool_chars=%d)", + cumulative_tool_chars, + ) + yield { + "type": "status", + "message": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…", + } + messages.append( + Message( + role="user", + content=( + "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、" + "ユーザーが求めた回答を最終的にまとめてください。" + ), + ) + ) + t0 = time.time() try: stream = cast(Any, raw_client).chat( model=model, @@ -252,9 +405,11 @@ def stream_chat_with_tools( stream=True, ) except Exception as exc: # noqa: BLE001 + logger.warning("final ollama chat call failed in %.2fs: %s", time.time() - t0, exc) yield {"type": "error", "message": str(exc)} return + logger.info("streaming final answer") for chunk in stream: thinking = _assistant_thinking(chunk) if thinking: @@ -262,6 +417,7 @@ def stream_chat_with_tools( content = _assistant_content(chunk) if content: yield {"type": "delta", "content": content} + logger.info("final answer stream finished elapsed=%.2fs", time.time() - t0) yield {"type": "done"} @@ -285,7 +441,7 @@ async def astream_chat_with_tools( return None while True: - event = await anyio.to_thread.run_sync(_next) + event = await anyio.to_thread.run_sync(_next, abandon_on_cancel=True) if event is None: break yield event diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index 08b330a..eb7a555 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -22,6 +22,15 @@ from ..tools.registry import ToolRegistry, default_registry logger = logging.getLogger("ollama_web.chat") +# System prompt injected when tools are enabled. Encourages the model to use +# tools iteratively rather than firing many searches at once. +_TOOL_SYSTEM_PROMPT = ( + "あなたはWeb検索・スクレイピング・ファイル取得ツールを利用できます。" + "一度の回答で必要以上に多くの検索を同時に行わず、結果を確認してから次の調査を行ってください。" + "各ツール結果は要約されて提供されるため、重要なポイントを把握して活用してください。" +) + + def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: """Convert raw JSON message dicts into ``ollama.Message`` objects.""" out: list[Message] = [] @@ -155,6 +164,12 @@ async def _chat_event_stream( if session_messages: messages = session_messages + messages + # Prepend a system prompt to encourage efficient tool usage when tools + # are available. Avoid duplicate injection if the session already has + # a system message. + if not any(getattr(m, "role", None) == "system" for m in messages): + messages.insert(0, Message(role="system", content=_TOOL_SYSTEM_PROMPT)) + host = request.app.state.settings.ollama_host from ollama_web import llm # local import to avoid circular at module load diff --git a/src/ollama_web/static/app.js b/src/ollama_web/static/app.js index 22fac53..f7a46a2 100644 --- a/src/ollama_web/static/app.js +++ b/src/ollama_web/static/app.js @@ -436,6 +436,11 @@ async function send() { assistantBody.innerHTML = ""; placeholderCleared = true; } + // Remove the "waiting" placeholder once actual content starts arriving. + const waiting = assistantBody.querySelector(".waiting-msg"); + if (waiting) { + waiting.remove(); + } assistantText += content; assistantBody.innerHTML = renderMarkdown(assistantText); highlightIn(assistantBody); @@ -472,6 +477,33 @@ async function send() { b.result.textContent = (ev.result || "").slice(0, 2000); chatEl.scrollTop = chatEl.scrollHeight; } + // Show a placeholder while waiting for the next ollama response so + // the UI does not look frozen between tool execution and deltas. + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + if (assistantBody.querySelector(".waiting-msg") === null) { + const waiting = document.createElement("span"); + waiting.className = "waiting-msg"; + waiting.style.color = "var(--muted)"; + waiting.style.fontStyle = "italic"; + waiting.textContent = "ollama が回答を生成中…"; + assistantBody.appendChild(waiting); + } + } else if (ev.type === "status") { + if (!placeholderCleared) { + assistantBody.innerHTML = ""; + placeholderCleared = true; + } + if (assistantBody.querySelector(".waiting-msg") === null) { + const waiting = document.createElement("span"); + waiting.className = "waiting-msg"; + waiting.style.color = "var(--muted)"; + waiting.style.fontStyle = "italic"; + waiting.textContent = ev.message || "処理中…"; + assistantBody.appendChild(waiting); + } } else if (ev.type === "thinking") { if (!thinkingBody) thinkingBody = addThinkingBubble(assistantExtras); thinkingBody.textContent += ev.content; diff --git a/src/ollama_web/tools/pdf.py b/src/ollama_web/tools/pdf.py index 8dc5abb..815c053 100644 --- a/src/ollama_web/tools/pdf.py +++ b/src/ollama_web/tools/pdf.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io from pathlib import Path from pypdf import PdfReader @@ -25,7 +26,7 @@ def extract_pdf_text(data: bytes, max_chars: int | None = None) -> str: limit = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars try: - reader = PdfReader(stream=data) + reader = PdfReader(stream=io.BytesIO(data)) except Exception as exc: # noqa: BLE001 return f"Failed to parse PDF: {exc}" |
