Spaces:
Running on Zero
fix: Run inference inside @spaces.GPU, where the weights actually exist
Browse filesThe Space was up, responsive, and emitting fluent multilingual noise at ~15
minutes a turn. Both symptoms were the same cause. Loading in the import window
got the model created and reported as cuda:0, but that is ZeroGPU's CUDA
*emulation*: it packs the tensors (17.6G, visible in the logs) and only
materialises them on real hardware for the duration of a @spaces.GPU call. Our
forward passes ran from a FastAPI route, so they read unmaterialised tensors --
which does not raise, it just returns garbage slowly.
app.py grows a stream_hook, None locally. app_space.py sets it to _gpu_stream, a
@spaces.GPU(duration=300) generator wrapping one whole agent turn.
The turn is the right unit. A turn is up to MAX_TOOL_STEPS generations plus the
tool calls between them, and they all mutate one KV cache; wrapping each
engine.stream() separately would put that shared state across a process boundary
on every step. One allocation per turn keeps it coherent, at the cost of not
reusing the cache between turns.
_collect() exists because ControlAgent.run() consumes self.stream directly and
would have bypassed the hook, leaving /api/chat running outside the GPU window
while /api/chat/stream ran inside it. It reads traces off the `done` event
rather than accumulating tool_end events, which also keeps the arguments that
run() dropped.
_gpu_probe stays, reduced to returning "ready": a @spaces.GPU function is only
detected when wired to a Gradio event handler, and _gpu_stream is driven by
FastAPI, so it cannot satisfy that itself.
Verified locally with a stand-in hook of the same shape that both /api/chat and
/api/chat/stream route through it, and that the MLX path is untouched when no
hook is installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +12 -0
- app.py +41 -6
- app_space.py +42 -10
|
@@ -210,6 +210,18 @@ routing transformers through `caching_allocator_warmup` and its direct
|
|
| 210 |
unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
|
| 211 |
against ~28GB for the 14B run locally.
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
**ZeroGPU platform gotchas, each learned by having the Space fail:**
|
| 214 |
- A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
|
| 215 |
solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
|
|
|
|
| 210 |
unavailable**, which is what forces a model small enough to carry in bf16: `Qwen/Qwen3-8B`, ~16GB
|
| 211 |
against ~28GB for the 14B run locally.
|
| 212 |
|
| 213 |
+
**All inference must run inside a `@spaces.GPU` call.** Loading the model in the import window is
|
| 214 |
+
necessary but not sufficient: ZeroGPU creates it under CUDA *emulation*, reports `cuda:0`, and packs
|
| 215 |
+
its tensors — only a `@spaces.GPU` call materialises them on real hardware. Forward passes anywhere
|
| 216 |
+
else do not fail. They read unmaterialised tensors and return fluent-looking multilingual noise at
|
| 217 |
+
roughly fifteen minutes a turn, which is the worst possible failure mode: a demo that is up,
|
| 218 |
+
responsive, and confidently wrong. `app.py` exposes a `stream_hook`, `None` locally; `app_space.py`
|
| 219 |
+
sets it to `_gpu_stream`, a `@spaces.GPU(duration=300)` generator wrapping one whole agent turn.
|
| 220 |
+
Wrapping the *turn* rather than each `engine.stream()` call is deliberate — a turn is several
|
| 221 |
+
generations sharing one KV cache, and splitting them across separate calls would put that shared
|
| 222 |
+
state on the far side of a process boundary each time. `_collect()` exists so `/api/chat` honours the
|
| 223 |
+
hook too; `ControlAgent.run()` consumes `self.stream` directly and would bypass it.
|
| 224 |
+
|
| 225 |
**ZeroGPU platform gotchas, each learned by having the Space fail:**
|
| 226 |
- A `@spaces.GPU` function is only detected if wired to a real Gradio event handler. One called
|
| 227 |
solely from a FastAPI route fails startup with "No @spaces.GPU function detected". Hence the
|
|
@@ -129,6 +129,41 @@ class ChatResponse(BaseModel):
|
|
| 129 |
elapsed_seconds: float
|
| 130 |
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def _to_wire_events(message: str, history: list[dict[str, str]]):
|
| 133 |
"""Translate agent events into the shape the browser client consumes.
|
| 134 |
|
|
@@ -137,7 +172,7 @@ def _to_wire_events(message: str, history: list[dict[str, str]]):
|
|
| 137 |
stable for the existing UI while the agent's own vocabulary stays clean.
|
| 138 |
"""
|
| 139 |
thoughts: list[str] = []
|
| 140 |
-
for event in
|
| 141 |
kind = event["type"]
|
| 142 |
if kind == "text":
|
| 143 |
yield {"type": "token", "content": event["text"]}
|
|
@@ -298,10 +333,10 @@ async def chat(req: ChatRequest) -> ChatResponse:
|
|
| 298 |
|
| 299 |
def _run():
|
| 300 |
with inference_lock:
|
| 301 |
-
return
|
| 302 |
|
| 303 |
try:
|
| 304 |
-
|
| 305 |
except Exception as exc:
|
| 306 |
print(f"[chat] {type(exc).__name__}: {exc}")
|
| 307 |
return ChatResponse(
|
|
@@ -310,9 +345,9 @@ async def chat(req: ChatRequest) -> ChatResponse:
|
|
| 310 |
)
|
| 311 |
|
| 312 |
return ChatResponse(
|
| 313 |
-
response=
|
| 314 |
-
tool_traces=[{"tool": t.
|
| 315 |
-
plots=
|
| 316 |
elapsed_seconds=round(time.time() - started, 2),
|
| 317 |
)
|
| 318 |
|
|
|
|
| 129 |
elapsed_seconds: float
|
| 130 |
|
| 131 |
|
| 132 |
+
# Set by app_space.py to a @spaces.GPU-decorated generator. ZeroGPU attaches
|
| 133 |
+
# real hardware only for the duration of such a call, so on the Space the whole
|
| 134 |
+
# turn -- every tool step, every KV-cache mutation -- has to happen inside one.
|
| 135 |
+
# Left None locally, where MLX needs no such thing.
|
| 136 |
+
stream_hook = None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _stream_events(message: str, history: list[dict[str, str]]):
|
| 140 |
+
"""The agent's raw events, through the GPU hook when one is installed."""
|
| 141 |
+
if stream_hook is not None:
|
| 142 |
+
yield from stream_hook(message, history)
|
| 143 |
+
else:
|
| 144 |
+
yield from get_agent().stream(message, history)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _collect(message: str, history: list[dict[str, str]]):
|
| 148 |
+
"""`ControlAgent.run()` over `_stream_events`, so /api/chat honours the hook.
|
| 149 |
+
|
| 150 |
+
ControlAgent.run consumes self.stream directly, which would bypass the hook
|
| 151 |
+
and run inference outside the GPU window.
|
| 152 |
+
"""
|
| 153 |
+
answer, plots, sources, stats = "", [], [], {}
|
| 154 |
+
traces: list[Any] = []
|
| 155 |
+
for event in _stream_events(message, history):
|
| 156 |
+
# The `done` event already carries the full traces, arguments included;
|
| 157 |
+
# accumulating tool_end events separately would only lose the arguments.
|
| 158 |
+
if event["type"] == "done":
|
| 159 |
+
answer = event["answer"]
|
| 160 |
+
traces = event["traces"]
|
| 161 |
+
plots = event["plots"]
|
| 162 |
+
sources = event["sources"]
|
| 163 |
+
stats = event["stats"]
|
| 164 |
+
return answer, traces, plots, sources, stats
|
| 165 |
+
|
| 166 |
+
|
| 167 |
def _to_wire_events(message: str, history: list[dict[str, str]]):
|
| 168 |
"""Translate agent events into the shape the browser client consumes.
|
| 169 |
|
|
|
|
| 172 |
stable for the existing UI while the agent's own vocabulary stays clean.
|
| 173 |
"""
|
| 174 |
thoughts: list[str] = []
|
| 175 |
+
for event in _stream_events(message, history):
|
| 176 |
kind = event["type"]
|
| 177 |
if kind == "text":
|
| 178 |
yield {"type": "token", "content": event["text"]}
|
|
|
|
| 333 |
|
| 334 |
def _run():
|
| 335 |
with inference_lock:
|
| 336 |
+
return _collect(message, req.history)
|
| 337 |
|
| 338 |
try:
|
| 339 |
+
answer, traces, plots, _sources, _stats = await _on_inference_thread(_run)
|
| 340 |
except Exception as exc:
|
| 341 |
print(f"[chat] {type(exc).__name__}: {exc}")
|
| 342 |
return ChatResponse(
|
|
|
|
| 345 |
)
|
| 346 |
|
| 347 |
return ChatResponse(
|
| 348 |
+
response=answer,
|
| 349 |
+
tool_traces=[{"tool": t.get("tool"), "status": t.get("status")} for t in traces],
|
| 350 |
+
plots=plots,
|
| 351 |
elapsed_seconds=round(time.time() - started, 2),
|
| 352 |
)
|
| 353 |
|
|
@@ -28,6 +28,7 @@ it the retriever has no corpus and every answer falls back to model knowledge.
|
|
| 28 |
from __future__ import annotations
|
| 29 |
|
| 30 |
import os
|
|
|
|
| 31 |
|
| 32 |
# Assigned, not setdefault: this module is the CUDA entry point by definition,
|
| 33 |
# and a stale Space variable must not be able to select something else. One did
|
|
@@ -87,21 +88,52 @@ def _build_agent_at_import() -> None:
|
|
| 87 |
# "[agent] retrieval failed". Force it onto the GPU here too. Embedding one
|
| 88 |
# throwaway string is what actually triggers the load.
|
| 89 |
get_embedder().encode_query("warmup")
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
-
@spaces.GPU(duration=
|
| 98 |
def _gpu_probe(text: str) -> str:
|
| 99 |
-
"""Satisfies ZeroGPU's startup validation
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
"""
|
| 104 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
with gr.Blocks() as _gpu_demo:
|
|
|
|
| 28 |
from __future__ import annotations
|
| 29 |
|
| 30 |
import os
|
| 31 |
+
from typing import Iterator
|
| 32 |
|
| 33 |
# Assigned, not setdefault: this module is the CUDA entry point by definition,
|
| 34 |
# and a stale Space variable must not be able to select something else. One did
|
|
|
|
| 88 |
# "[agent] retrieval failed". Force it onto the GPU here too. Embedding one
|
| 89 |
# throwaway string is what actually triggers the load.
|
| 90 |
get_embedder().encode_query("warmup")
|
| 91 |
+
# Route every turn through the GPU-decorated generator above.
|
| 92 |
+
app_module.stream_hook = _gpu_stream
|
| 93 |
+
print("[space] agent and embedder ready, GPU stream hook installed")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# One allocation per user turn, not per engine call. A turn is up to
|
| 97 |
+
# MAX_TOOL_STEPS generations plus the tool executions between them, and they all
|
| 98 |
+
# mutate the same KV cache; splitting them across separate @spaces.GPU calls
|
| 99 |
+
# would put that shared state on the far side of a process boundary each time.
|
| 100 |
+
# 300s is the ceiling for a turn that actually calls two tools.
|
| 101 |
+
@spaces.GPU(duration=300)
|
| 102 |
+
def _gpu_stream(message: str, history: list) -> Iterator[dict]:
|
| 103 |
+
"""Run one whole agent turn with real hardware attached.
|
| 104 |
+
|
| 105 |
+
Everything about ControlAI's inference has to happen inside a call like this
|
| 106 |
+
one. ZeroGPU creates the model under CUDA *emulation* at import and packs its
|
| 107 |
+
tensors; only a @spaces.GPU call materialises them on a real device. Running
|
| 108 |
+
the forward passes anywhere else does not fail loudly -- it reads
|
| 109 |
+
unmaterialised tensors and returns fluent-looking multilingual noise at a few
|
| 110 |
+
minutes per turn, which is exactly what the Space did before this existed.
|
| 111 |
+
|
| 112 |
+
`message` and `history` are plain data. The agent is reached through the
|
| 113 |
+
module global and never passed in: ZeroGPU marshals arguments across a
|
| 114 |
+
process boundary and would try to share the model's CUDA tensors, hanging
|
| 115 |
+
with no output at all.
|
| 116 |
+
"""
|
| 117 |
+
yield from app_module.get_agent().stream(message, history)
|
| 118 |
|
| 119 |
|
| 120 |
+
@spaces.GPU(duration=60)
|
| 121 |
def _gpu_probe(text: str) -> str:
|
| 122 |
+
"""Satisfies ZeroGPU's startup validation.
|
| 123 |
|
| 124 |
+
A @spaces.GPU function is only detected if it is wired to a real Gradio event
|
| 125 |
+
handler, and _gpu_stream is driven by FastAPI rather than by Gradio, so it
|
| 126 |
+
cannot serve that purpose itself. This one exists to be wired to the hidden
|
| 127 |
+
button below.
|
| 128 |
"""
|
| 129 |
+
return "ready"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Both run at import, in this order: the index first (the agent touches retrieval
|
| 133 |
+
# as it starts), then the agent -- which installs _gpu_stream, so it has to come
|
| 134 |
+
# after that function exists.
|
| 135 |
+
_fetch_index()
|
| 136 |
+
_build_agent_at_import()
|
| 137 |
|
| 138 |
|
| 139 |
with gr.Blocks() as _gpu_demo:
|