Gemini-3.7-flash: schema-constrained JSON output degenerates into repeated "0" until maxOutputTokens (regression vs gemini-3-flash-preview)

Summary

Under responseMimeType: "application/json" + responseSchema, gemini-3.7-flash
intermittently enters a decode loop on prompts containing many near-identical
items: it emits a valid JSON prefix, then an integer field’s digits repeat
(...“score”: 20000000000000...) endlessly until maxOutputTokens
(finishReason: MAX_TOKENS, truncated/unparseable JSON). Without an explicit
cap it runs to the model’s 65,536 default on every occurrence.

The identical request never degenerates on gemini-3-flash-preview.

Environment

  • Model: gemini-3.7-flash (fails) vs gemini-3-flash-preview (clean)
  • API: Gemini Developer API (generativelanguage), paid tier
  • SDK: @google/genai 1.37.0, Node.js 20
  • Config: responseMimeType: application/json, responseSchema (array of
    {id: STRING, score: INTEGER, comment: STRING} with min/maxItems),
    thinkingConfig: { thinkingLevel: LOW }

Reproduction

Attached standalone script (repro.js, fully synthetic data): 10 grading items
that differ only in a decimal value, schema-constrained JSON output.

  • gemini-3.7-flash: ~1 in 3 runs ends MAX_TOKENS with a tail of repeated
    zeros (measured 33% on the synthetic payload; up to 100% per attempt on
    equivalent production payloads).
  • gemini-3-flash-preview: 0 degenerate runs (dozens attempted).
  • temperature: 0 does NOT avoid it — the zero-loop appears to be a
    deterministic attractor for some inputs.

Expected: STOP with ~400 output tokens, as gemini-3-flash-preview produces.

Impact

Production education workload (grading student papers, JSON verdicts): 241
runaway calls across 3 days, each billed to the 65,536-token default cap
(~US$60 of unwanted output tokens) before we deployed maxOutputTokens caps,
per-chunk retries, and a fallback to gemini-3-flash-preview. The failure rate
made several papers temporarily ungradable (every retry re-entered the loop on
the same content).

Secondary observations on the same model/API (can file separately if preferred)

  1. Intermittent bare 400 INVALID_ARGUMENT (no detail) on well-formed
    generation requests — 3–10/day across our traffic; the identical request
    succeeds on immediate reissue.
  2. Rare 200 responses with an empty candidate (no text parts,
    finishReason: STOP) on requests that normally return full JSON.

Ask

Is the constrained-decoding repetition loop a known issue in the 3.7-flash
decoder, and is a fix planned? Happy to provide more failing payloads or run
canary tests.

Standalone reproduction script

// Minimal reproduction: gemini-3.7-flash degenerates into repeated "0" digits
// until maxOutputTokens under schema-constrained JSON decoding when the prompt
// contains many near-identical items. gemini-3-flash-preview never does on the
// identical request. Fully synthetic data — safe to share.
//
// Usage:  GEMINI_API_KEY=<key> node _gemini-37-degeneration-repro.js [trials]
// Expected: roughly 1 in 3 gemini-3.7-flash trials ends with
// finishReason=MAX_TOKENS and a tail of repeated zeros; all
// gemini-3-flash-preview trials end STOP with ~400 output tokens.
// temperature: 0 does NOT avoid the failure mode.
const { GoogleGenAI, ThinkingLevel, Type } = require('@google/genai')

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
const TRIALS = Number(process.argv[2] || 5)

// 10 near-identical grading items differing only in decimal values — the
// repetitive-input shape that triggers the decode loop.
const items = [18.9, 23.8, 7.25, 6.04, 12.5, 3.75, 9.2, 15.6, 4.85, 21.4].map((v, i) => ({
  id: 'q' + (i + 1),
  totalScore: 2,
  question:
    '<p>Convert the following decimal into the simplest fraction.<br />\\(' +
    v +
    ' =\\) __________</p>',
  studentAns: '<p><strong>' + v * 20 + '/20</strong></p>',
}))

const PROMPT =
  'You are an AI that checks the answers of students in a practice paper. ' +
  'For each question return a score between 0 and totalScore, and a concise ' +
  'HTML comment (use MathJax \\( \\) for math) explaining the score. ' +
  'The comment must be non-empty.'

const schema = {
  type: Type.ARRAY,
  minItems: 10,
  maxItems: 10,
  items: {
    type: Type.OBJECT,
    properties: {
      id: { type: Type.STRING },
      score: { type: Type.INTEGER },
      comment: { type: Type.STRING },
    },
  },
}

const runTrial = async (model) => {
  const r = await ai.models.generateContent({
    model,
    contents: [PROMPT, 'Questions and answers:\n' + JSON.stringify(items)],
    config: {
      responseMimeType: 'application/json',
      responseSchema: schema,
      // Cap only bounds the damage — without it the runaway emits zeros all
      // the way to the model's 65,536 default.
      maxOutputTokens: 8192,
      thinkingConfig: { thinkingLevel: ThinkingLevel.LOW },
    },
  })
  const fin = r.candidates[0].finishReason
  const out = (r.usageMetadata && r.usageMetadata.candidatesTokenCount) || 0
  const tail = String(r.text || '').slice(-40)
  return { fin, out, tail }
}

;(async () => {
  for (const model of ['gemini-3.7-flash', 'gemini-3-flash-preview']) {
    let degenerate = 0
    for (let t = 1; t <= TRIALS; t++) {
      const { fin, out, tail } = await runTrial(model)
      const bad = fin === 'MAX_TOKENS'
      if (bad) degenerate++
      console.log(
        model +
          ' trial ' +
          t +
          ': ' +
          fin +
          ' outputTokens=' +
          out +
          (bad ? ' tail=' + JSON.stringify(tail) : '')
      )
    }
    console.log('=> ' + model + ': ' + degenerate + '/' + TRIALS + ' degenerate runs\n')
  }
  process.exit(0)
})().catch((e) => {
  console.error(e)
  process.exit(1)
})

I have a similar issue and in general this seems really bad.

For my workload it should output a large json schema and in 40% of cases it wants to close the generation but is confused by not being able to output the finish-token - EOS or whatever they are using - and instead write “… Finish. Done. Cancel. Goodbye…” until the output token limit is reached, i suspect the finish token is what the model wants to output but can’t as that is bound to first closing the json. That closing statement is hidden behind other tokens indicating the wish to end the conversation though like the “Finish” token I suspect.

I think the failure class has a cheap solution: when the masked EOS token becomes the sampler’s top choice inside a schema generation, emit the shortest legal completion to close the schema OR the current level of a nested schema. The output might have other problems, but would be structurally valid and parseable, versus today’s max-token unparseable garbage. Maybe also just send a flag that something like that happened. Like a finishReason flag.

Alternatively or additionally expose user-supplied formal grammars with bounded repetition, like llama.cpp’s GBNF. Pretty sure with that I would find some way around the issue and formal grammars are much better in general.