Nodes/Helm/Helm Lab · Record activations
ComfyUI Node

Helm Lab · Record activations

Build yourself a slider in two passes

By U-N-B-R-A-N-D-E-D·Created 25 days ago·Updated 10 days ago· 7
Helm Lab · Record activations
  • model
  • model
◄tagpos►
◄block20►
◄cond_onlyfalse►

Helm's whole trick is that a direction the model already understands can be read out and reused as a scalar. Helm Collect is the reading half: it runs your normal generation, watches one transformer block, and saves that run's average activation into a small file named after whatever you tagged it.

Do that twice - once with the "more of this" prompt, once with the "less of this" prompt - and you have both halves of an axis. Helm Axis subtracts them, Helm Apply dials the result in. No training, no dataset, no captions.

What it's actually doing

It takes a MODEL and gives it back, so it slots in where Helm Apply does: between loader and sampler. Every sampling step it reads block block's output, accumulates a sum, divides by the step count at the end and writes one file. On tuple-returning blocks it uses the same visual-stream slot rule as injection, so on Flux it reads the image stream, not the text one. A full sample per pass, by nature - which is why sweeps want Helm Probe instead.

The file lands in ComfyUI/user/steer/<tag>.safetensors and is self-describing: the header carries dim, block, model and module (which attribute the block list came from). Safetensors with metadata, so no pickle and no sidecar to lose.

Writes always go to user/steer/. Reads check user/steer/, then models/steer/, then the bundled example_axes/, so your own axes win over the shipped ones.

The four inputs

Only one has a real decision in it.

  • model - your patcher, from the loader.
  • tag - the filename, nothing more. pos, neg, warm, cold. There's no registry, so a typo doesn't error here: it silently writes a new file, and you find out one node later when Helm Axis says the tag you meant doesn't exist.
  • block - the block index to read, and the one to think about. It must match where you later apply, because the axis is "the direction of change at this block". Collect at 20, apply at 20; collect at 20 and apply at 8 and you get noise, not a weaker effect. Starting points: 20 of 50 on H3, 15 of 30 on Z-Image/Lumina2.
  • cond_only - false by default, which is what the published results used. true averages only conditional batch items, which the tooltip recommends for CFG models - otherwise you average the positive and negative passes together and the axis picks up guidance as well as content. The shipped axes were not measured with it on. Note that at CFG 1, where most 2026 models run, there's no uncond half in the batch, so it has little to separate.

Output: one model socket. Wire it into your sampler exactly as you would have wired the loader.

The two-pass recipe

The graph shape is the boring part, and it's what people get wrong.

UNETLoader → HelmCollect(tag=pos, block=20) → KSampler → ...
UNETLoader → HelmCollect(tag=neg, block=20) → KSampler → ...

Run the first graph with prompt A - the thing you want more of - and the second with prompt B, the near-miss contrast. Keep everything else identical: model, seed, sampler, steps, resolution. You're measuring the difference between two prompts, so any other difference is contamination.

Then:

HelmCollect(pos) ┐
HelmCollect(neg) ┴→ HelmAxis(pos_tag=pos, neg_tag=neg, axis=my_direction) → HelmApply(axis=my_direction)

Then bypass the Collect nodes and keep the Apply. The axis file is permanent; the collection graph was scaffolding.

The pack's own density pair is "a corridor of glass server racks" versus an empty room - same subject, same framing, one property different. "A photo of a cat" versus "a watercolour of a spaceship" differs in everything, and subtracting it gives you a direction that means nothing specific.

You need a real render each pass. The file is written at the end of sampling and needs at least one step to flush. A Collect node with nothing downstream never runs, so there's no file - which looks like a bug and isn't.

Install

ComfyUI Manager → search Helm → install → restart. Or:

cd ComfyUI/custom_nodes
git clone https://github.com/U-N-B-R-A-N-D-E-D/comfyui-helm.git comfyui-helm

Restart, then search Templates for helm, or open example_workflows/krea2_helm_collect.json as a working reference. Dependencies are safetensors and numpy on Python 3.10+, both already present - no dependency-hell tax, which is one of the nicer things about a pack that doesn't need transformers. And as with the rest of the pack: custom nodes don't install on Comfy Cloud, so this is local/self-hosted only.

Where people get burned

Nothing was written. The tag never reached a sampler, or the Collect node isn't upstream of an output. Check the console - it logs [helm] collecting '<tag>' at block <n> -> <path> when the node is built, so a missing line means it never executed. And the written path is always user/steer/, even if you read axes from elsewhere.

Wrong architecture, wrong numbers. On a model that isn't in calibration.json, Helm takes the first known block-list attribute with more than four entries. Usually right for a DiT, useless for a UNet. On an unknown model, run Helm Probe first to see which block actually separates your prompts.

The resulting axis does nothing. Very common, and not a bug in your graph: in the 78-axis Z-Image stress test only 10 of 53 labelled directions held up. Most contrast pairs don't produce a usable direction - the change is spread across the residual stream rather than concentrated. Sharpen the contrast before you raise the strength; cranking to 3.0 mostly buys a washed-out frame.

CategoryHelm/Lab

Inputs (4)

NameTypeDefaultDescription
modelMODELThe model to record from. The model passes through unchanged.
tagSTRINGposName of the recording. Record once with a prompt that has the idea and once without, with different names.
blockINT200–512Transformer block index to read.
cond_onlyBOOLEANfalseDefault False preserves the published result. True (recommended for CFG models) averages only cond batch items; current axes were NOT measured with cond_only.

Outputs (1)

NameTypeDescription
modelMODEL—